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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

前言

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

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

协同编辑数据模型

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

Delta 数据模型

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

{
  ops: [
    { insert: 'Gandalf', attributes: { bold: true } },
    { insert: ' the ' },
    { insert: 'Grey', attributes: { color: '#cccccc' } }
  ]
}

如上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 集成方面有着出色的表现。

{
  type: 'insert_text',
  path: [0, 0],
  offset: 15,
  text: 'A new string of text to be inserted.',
}

         我就不解析上面的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 依赖

// 下载 Quill
npm install quill@1.3.4

// 下载Yjs
npm install yjs

初始化Quill编辑器 

<template>
  <div id="edit"></div>
</template>

<script setup>
import Quill from "quill";
import "quill/dist/quill.snow.css"; // 使用了 snow 主题色

import { onMounted } from "vue";

onMounted(() => {
  // 获取dom需要在mounted后
  new Quill("#edit", {
    theme: "snow",
  });
});
</script>

<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

 //   初始化YJS
  // A Yjs document holds the shared data
  const ydoc = new Y.Doc();

  // Define a shared text type on the document这个是拿到需要协同的 Delta 数据
  const ytext = ydoc.getText("quill");

  //   绑定 quill与YJS
  const binding = new QuillBinding(ytext, quill);

  //   使用webrtc实现连接
  const provider = new WebrtcProvider("quill-demo-room", ydoc);

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

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

封装类

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

// 导出Quill实体类

import Quill from "quill";
import "quill/dist/quill.snow.css"; // 使用了 snow 主题色

export class myQuill {
  constructor(selector) {
    // 初始化 quill 文档操作对象
    this.quill = new Quill(selector, {
      theme: "snow",
      placeholder: "请输入内容...",
    });
  }
}


// 导出 Yjs 实体类
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
import { QuillBinding } from "y-quill";

export class myYjs {
  // 需要传入绑定对象
  constructor(quill) {
    // A Yjs document holds the shared data
    this.ydoc = new Y.Doc();

    // Define a shared text type on the document这个是拿到需要协同的 Delta 数据
    const ytext = this.ydoc.getText("quill");

    //   绑定 quill与YJS
    const binding = new QuillBinding(ytext, quill.quill);

    //   使用webrtc实现连接
    const provider = new WebrtcProvider("quill-demo-room", this.ydoc);
  }
}

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

onMounted(() => {
  // 获取dom需要在mounted后
  const quill = new myQuill("#edit");

  //   初始化YJS
  const yjs = new myYjs(quill);
});

添加用户光标

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

npm i quill-cursors

 绑定光标信息

 

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

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

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

quill.insertText(0, 'Hello', 'bold', true);

quill.insertText(5, 'Quill', {
  'color': '#ffff00',
  'italic': true
});

如何向末尾追加文本呢

获取文本编辑器长度 getLength

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

var length = quill.getLength();
  var length = quill.getLength();

  // 向末尾追加
  quill.insertText(length, "quill.getLength()");

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

 quill.insertText(0, "Hello", "bold", true);

  var length = quill.getLength();

  // 向末尾追加
  quill.insertText(length, "quill.getLength()");

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

 不再换行。

insertText实际使用中的问题

1. 仅支持插入字符串

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

2.  getLength 使用需谨慎

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

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

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

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

 formatting 格式化API

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

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调用实现双向即时通信 

module.exports = () => {
  console.log("等待初始化 WS 服务...");
  // 搭建ws服务器
  const { WebSocketServer } = require("ws");
  const wss = new WebSocketServer({
    port: 9000,
  });

  console.log(" WS 服务初始化成功连接地址ws://localhost:9000");

  wss.on("connection", (ws, req) => {
    console.log("Yjs 客户端连接 ws 服务");
    // ws.send("我是服务端"); // 向当前客户端发送消息
  });
};

Yjs客户端调用

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

const doc = new Y.Doc()
const wsProvider = new WebsocketProvider('ws://localhost:1234', 'my-roomname', doc)

wsProvider.on('status', event => {
  console.log(event.status) // logs "connected" or "disconnected"
})

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

协同编辑

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

 撤销与重做

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

// MyQuill 类

  // 撤销
  undo() {
    this.quill.history.undo();
  }

  // 重做
  redo() {
    this.quill.history.redo();
  }

调用

 // 撤销
    case "icon-chexiao":
      quill.undo();
      break;

    // 重做
    case "icon-zhongzuo":
      quill.redo();
      break;

格式化

 // 格式化
  format(opt, color) {
    // 将加粗\斜体\删除线\下划线\颜色等操作 封装一个函数,因此,就需要先获取样式,才能判断是否已经有样式
    // 还需要获取用户的选择,可能是给某些字符添加样式
    // 获取用户选择 ** 这里需要传递参数,不然会导致焦点移出编辑器,选中失效这个 true 非常关键
    var range = this.quill.getSelection(true);

    if (!range) return console.warn("User cursor is not in editor");
    let { index, length } = range; // index 是当前光标的索引,length 表示当前选择的长度
    // 获取样式 检索给定范围内文本的所用格式(加粗 斜体都是块作用域,是需要指定长度的,因此,用户没有选择,则默认不作用,不像标题等,是行作用域)
    let { bold, italic, strike, underline } = this.quill.getFormat(
      index,
      length
    );
    // "icon-cuti": bold,
    // "icon-italic": italic,
    // "icon-strikethrough": strike,
    // "icon-zitixiahuaxian": underline,
    // "icon-zitiyanse": color,
    // 拿到用户操作的映射,判断有没有当前属性,没有则添加,有,则删除
    if (opt === "icon-cuti")
      this.quill.formatText(index, length, "bold", !bold);
    if (opt === "icon-italic")
      this.quill.formatText(index, length, "italic", !italic);
    if (opt === "icon-strikethrough")
      this.quill.formatText(index, length, "strike", !strike);
    if (opt === "icon-zitixiahuaxian")
      this.quill.formatText(index, length, "underline", !underline);
    if (opt === "color") this.quill.formatText(index, length, "color", color);
  }

实现图片上传

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

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

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

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

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

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

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

 实现效果

实现文件共享

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

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

// 考虑是否登录
  const user = JSON.parse(sessionStorage.getItem("user"));
  if (to.path !== "/login") {
    if (!user) {
      ElMessage.error("请先登录");
      // 进行数据转存
      if (to.matched[0].path === "/invited/:fileid") {
        // 向 login 添加信息
        let { fileid } = to.params;
        return next({ path: "/login", query: { fileid, ...to.query } });
      }
      return next({ path: "/login" });
    }
  }

登录按钮

if (router.currentRoute.value.query.fileid) {
    let { fileid, filename, username } = router.currentRoute.value.query;
    return router.push({
      path: `/invited/${fileid}`,
      query: { filename, username },
    });
  }
  router.push("/home");

页面开发

 效果如下

 

 实现粘贴板

const execContent = (text) => {
  if (navigator.clipboard) {
    // clipboard api 复制
    navigator.clipboard.writeText(text);
  } else {
    var textarea = document.createElement("textarea");
    document.body.appendChild(textarea);
    // 隐藏此输入框
    textarea.style.position = "fixed";
    textarea.style.clip = "rect(0 0 0 0)";
    textarea.style.top = "10px";
    // 赋值
    textarea.value = text;
    // 选中
    textarea.select();
    // 复制
    document.execCommand("copy", true);
    // 移除输入框
    document.body.removeChild(textarea);
  }

};

文件版本控制

        这里有一个注意事项

/**
 * 版本控制说明
 *  1. 客户端一定是永远调用一个接口,因从需要处理是否处于创建状态,
 *  2. 根据files 表的 currenthead 当前指针 是否为空 判断是否是第一次创建
 *      createVersion 中可以直接 next 跳过创建过程
 *  3. 更新版本还需要控制时间
 *  4. 更新版本的同时,还需要更新文件表信息 currenthead 字段
 */

// 更新版本(有一定的时间周期,不然一个文件会有很多版本)
router.post("/updateVersion", versionCtrl.createVersion, fileCtrl.updateFiles);

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

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

// 初始化文本编辑器
  init(data) {
    // 处理数据(最大程度还原数据)
    let _T = data
      .replace(/[\r]/g, "#r#")
      .replace(/[\n]/g, "#n#")
      .replace(/[\t]/g, "#t#");
    let delta = JSON.parse(_T);
    /**
     * 需要先处理特殊字符不然转不了JSON
     * 然后再根据特性转回来不然该换行的地方没有换行
     */
    delta.forEach((i, index) => {
      i.insert = i.insert
        .toString()
        .replace(/#n#/g, "\n")
        .replace(/#r#/g, "\r")
        .replace(/#t#/g, "\t");
    });
    this.quill.setContents(delta);
  }

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

// 表情转码
export const utf16toEntities = (str) => {
  const patt = /[\ud800-\udbff][\udc00-\udfff]/g; // 检测utf16字符正则
  str = str.replace(patt, (char) => {
    let H;
    let L;
    let code;
    let s;

    if (char.length === 2) {
      H = char.charCodeAt(0); // 取出高位
      L = char.charCodeAt(1); // 取出低位
      code = (H - 0xd800) * 0x400 + 0x10000 + L - 0xdc00; // 转换算法
      s = `&#${code};`;
    } else {
      s = char;
    }

    return s;
  });

  return str;
};
// 表情解码
export const entitiestoUtf16 = (strObj) => {
  const patt = /&#\d+;/g;
  const arr = strObj.match(patt) || [];

  let H;
  let L;
  let code;

  for (let i = 0; i < arr.length; i += 1) {
    code = arr[i];
    code = code.replace("&#", "").replace(";", "");
    // 高位
    H = Math.floor((code - 0x10000) / 0x400) + 0xd800;
    // 低位
    L = ((code - 0x10000) % 0x400) + 0xdc00;
    code = `&#${code};`;
    const s = String.fromCharCode(H, L);
    strObj = strObj.replace(code, s);
  }
  return strObj;
};

 初始化 socket 服务

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

io.on("connection", (socket) => {
  socket.join("room 237");
  
  console.log(socket.rooms); // Set { <socket.id>, "room 237" }

  socket.join(["room 237", "room 238"]);

  io.to("room 237").emit("a new user has joined the room"); // broadcast to everyone in the room
});

实现效果如下

整体效果

可优化点 

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

总结

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

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

“Yjs + Quill 实现文档多人协同编辑器开发(基础+实战)” 的相关文章