Yjs + Quill 实现文档多人协同编辑器开发(基础+实战)
前言
多人协同开发确实是比较难的知识点,在技术实现上有一定挑战,但随着各种技术库的发展,目前已经有了比较成熟的解决方案。今介绍 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 dataconst ydoc = new Y.Doc();// Define a shared text type on the document(这个是拿到需要协同的 Delta 数据)const ytext = ydoc.getText("quill");// 绑定 quill与YJSconst 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 datathis.ydoc = new Y.Doc();// Define a shared text type on the document(这个是拿到需要协同的 Delta 数据)const ytext = this.ydoc.getText("quill");// 绑定 quill与YJSconst binding = new QuillBinding(ytext, quill.quill);// 使用webrtc实现连接const provider = new WebrtcProvider("quill-demo-room", this.ydoc);}
}
直接初始化即可,后续在拿的是对象进行操作:
onMounted(() => {// 获取dom需要在mounted后const quill = new myQuill("#edit");// 初始化YJSconst 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;// 移动文件到第一参数指定位置 若有错误 返回500file.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 继续创作,最后,大家多多支持呀,点赞收藏哦
相关文章:

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

个性化定制界面还是极简版原装界面?我的选择是……
个性化定制界面和极简版原装界面,哪一个你用起来更加顺手呢,相比之下你更喜欢哪一个?来聊一聊原因吧! 一、我的观点和选择 个性化定制界面和极简版原装界面,二者各有优缺点。 (一)极简版原装…...

C++ STL list容器使用教程
文章目录 引用头文件初始化赋值遍历 list 容器迭代器list 常用方法删除元素插入元素 合并列表 list 翻译为列表,列表容器被实现为双向链表,因此它提供了对其数据的双向顺序访问。 List 不提供快速随机访问,它只支持双向顺序访问。 List 允许…...

go web之一:hello world快速上手+handle(http.Handle和http.HandleFunc的区别与联系)
前情提要: 需要安装好go的环境和VSCode的go插件。 hello world快速上手 1、创建go.mod 在项目根目录下打开命令行,或者直接用VSCode中的终端。输入命令 go mod init github.com/solenovex/web-tutorial 然后就能看到项目结构中多了一个go.mod 2、…...

【Postman】postman生成测试报告完整步骤(包含命令与newman安装教程链接)
文章目录 一、前提二、导出Postman脚本三、生成测试报告 一、前提 前提准备: 已安装好Newman 指引文章:Newman安装与环境配置完整版文章 Newman是一款基于nodejs开发的可以运行Postman脚本的工具,并可以生成测试报告。 二、导出Postman脚本…...

一、C#—概述环境安装(1)
🌻🌻 目录 一、 C#概述1.1 为啥学习C#1.2 TIBOE编程语言排行榜1.3 IEEE编程语言排行榜1.4 什么是C#1.5 C#创始人1.6 C#发展历史1.7 C#特点1.8 C#与Java1.9 .NET Framework1.10 C# 与 .NET Framework1.11 C#得应用领域1.12 C#能做什么 二、开发环境得安装…...

C# 实现ComboBox下拉框控件
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System...

leetcode做题笔记119. 杨辉三角 II
给定一个非负索引 rowIndex,返回「杨辉三角」的第 rowIndex 行。 在「杨辉三角」中,每个数是它左上方和右上方的数的和。 思路一:模拟题意 int* getRow(int rowIndex, int* returnSize){int* ret malloc(sizeof(int)*(rowIndex1));ret[0]…...

Dolphin for Mac(Wii游戏模拟器)配置指南
Wii模拟器Dolphin Mac是款适合Mac电脑中的游戏玩家们使用的模拟器工具。Wii模拟器Dolphin Mac官方版支持直接运行游戏镜像文件,玩家可以将游戏ISO拷贝到某一个文件夹中统一进行管理。Wii模拟器Dolphin Mac除了键盘和鼠标外,还支持配合原版的Wii遥控器操作…...

Java,Linux,Mysql小白入门
Java入门 java后端__阿伟_的博客-CSDN博客 Linux与Git入门 Linux与Git入门教程__阿伟_的博客-CSDN博客 Mysql入门 Linux与Git入门教程__阿伟_的博客-CSDN博客...

代码随想录算法训练营第二十四天|理论基础 77. 组合
理论基础 其实在讲解二叉树的时候,就给大家介绍过回溯,这次正式开启回溯算法,大家可以先看视频,对回溯算法有一个整体的了解。 题目链接/文章讲解:代码随想录 视频讲解:带你学透回溯算法(理论篇…...

macos安装zsh
https://www.cnblogs.com/xuLessReigns/p/11005435.html mac下安装autojump brew install autojump 1,安装zsh,执行 sh -c "$(curl -fsSL https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" 2,将zsh设置…...

【Unity】预制体材质变(Clone)克隆体问题
1、排查代码是否存在直接修改预制体的材质为克隆体。 解决:删了这段代码。 2、双击Prefab文件进入预制体编辑模式时,会执行预制体身上的脚本方法Awake、Start等(生命周期方法),所以要排查这些方法里是否有克隆…...

python“魂牵”京东商品历史价格数据接口(含代码示例)
要通过京东的API获取商品详情历史价格数据,您可以使用京东开放平台提供的接口来实现。以下是一种使用Java编程语言实现的示例,展示如何通过京东开放平台API获取商品详情历史价格数据: 首先,确保您已注册成为京东开放平台的开发者…...

密码算法、密钥体系---安全行业基础篇1
一、密码算法 密码算法是一种数学和计算方法,用于保护数据的机密性和安全性。不同的密码算法使用不同的数学原理和技术来加密和解密数据。以下是一些常见的密码算法类型: 1. **对称密码算法:** 特点:相同的密钥用于加密和解密数…...

Java工具类记录
HTML转word 相关依赖 <dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId><version>4.1.2</version></dependency>import org.apache.poi.poifs.filesystem.DirectoryEntry; import org.apache.poi…...

DVWA靶场搭建
目录 配置环境: 1、将下载好的压缩包放置php的WWW根目录下 2、改文件配置 3、查看mysql用户名和密码,将其修改值靶场配置文件中 4、完成后我们就可以在浏览器输入127.0.0.1/dvwa进入靶场 测试XSS注入: 配置环境: githhub下…...

Uniapp笔记(二)uniapp语法1
一、本节项目预备知识 1、效果演示 2、常见组件 1、view组件 视图容器,它类似于传统html中的div,用于包裹各种元素内容。 2、swiper组件 swiper是滑动视图容器,经常看到的轮播图就是通过它来完成的 swiper-item是swiper子组件…...

【1day】PHPOK cms SQL注入学习
目录 一、漏洞描述 二、资产测绘 三、漏洞复现 四、漏洞修复 一、漏洞描述 PHPOK CMS是一个基于PHP语言开发的开源内容管理系统(CMS)。它提供了一个强大的平台,用于创建和管理网站内容。PHPOK CMS具有灵活的模块化架构,可以根据网站的需求进行定制和扩展。PHPOK CMS存…...

线程同步与互斥
目录 前言:基于多线程不安全并行抢票 一、线程互斥锁 mutex 1.1 加锁解锁处理多线程并发 1.2 如何看待锁 1.3 如何理解加锁解锁的本质 1.4 CRAII方格设计封装锁 前言:基于线程安全的不合理竞争资源 二、线程同步 1.1 线程同步处理抢票 1.2 如何…...

电子词典dictionary
一、项目要求: 1.登录注册功能,不能重复登录,重复注册。用户信息也存储在数据库中。 2.单词查询功能 3.历史记录功能,存储单词,意思,以及查询时间,存储在数据库 4.基于TCP,支持多客户…...

【python爬虫】10.指挥浏览器自动工作(selenium)
文章目录 前言selenium是什么怎么用设置浏览器引擎获取数据解析与提取数据自动操作浏览器 实操运用确认目标分析过程代码实现 本关总结 前言 上一关,我们认识了cookies和session。 分别学习了它们的用法,以及区别。 还做了一个项目:带着小…...

QT文件对话框,将标签内容保存至指定文件
一、主要步骤 首先,通过getSaveFileName过去想要保存的文件路径及文件名,其次,通过QFile类实例化一个文件对象,再读取文本框中的内容,最后将读取到的内容写入到文件中,最后关闭文件。 1.txt即为完成上述操作…...

C#,《小白学程序》第十一课:阶乘(Factorial)的计算方法与代码
1 文本格式 /// <summary> /// 阶乘的非递归算法 /// </summary> /// <param name"a"></param> /// <returns></returns> private int Factorial_Original(int a) { int r 1; for (int i a; i > 1; i--) { …...

MySQL 数据库常用命令大全(完整版)
文章目录 1. MySQL命令2. MySQL基础命令3. MySQL命令简介4. MySQL常用命令4.1 MySQL准备篇4.1.1 启动和停止MySQL服务4.1.2 修改MySQL账户密码4.1.3 MySQL的登陆和退出4.1.4 查看MySQL版本 4.2 DDL篇(数据定义)4.2.1 查询数据库4.2.2 创建数据库4.2.3 使…...

【数学】【书籍阅读笔记】【概率论】应用随机过程概率论模型导论 by Sheldon M.Ross 第一章 概率论引总结与习题题解 【更新中】
文章目录 前言1 第一章 概率论引论 总结1.1 样本空间与事件1.2 定义在事件上的概率1.3 条件概率1.4 独立事件 2 一些有用的重要结论/公式/例题3 重要例题例 1.11 3 习题题解题1题2 4 习题总结 前言 1 第一章 概率论引论 总结 第一章从事件的角度引出样本空间、事件、概率的基本…...

posexplode函数实战总结
目录 1、建表和准备数据 2、炸裂实践 3、错误炸裂方式 4、当字段类型为string,需要split一下 对单列array类型的字段进行炸裂时,可以使用lateral view explode。 对多列array类型的字段进行炸裂时,可以使用lateral view posexplode。 1…...

QTday3(对话框、发布软件、事件处理核心机制)
一、Xmind整理: 二、上课笔记整理: 1.消息对话框(QMessageBox) ①基于属性版本的API QMessageBox::QMessageBox( //有参构造函数名QMessageBox::Icon icon, //图标const Q…...

el-date-picker限制选择的时间范围
<el-date-pickersize"mini"v-model"dateTime"value-format"yyyy-MM-dd HH:mm:ss"type"datetimerange"range-separator"~"start-placeholder"开始日期"end-placeholder"结束日期":picker-options&quo…...

Scala中的Actor模型
Scala中的Actor模型 概念 Actor Model是用来编写并行计算或分布式系统的高层次抽象(类似java中的Thread)让程序员不必为多线程模式下共享锁而烦恼。Actors将状态和行为封装在一个轻量的进程/线程中,但是不和其他Actors分享状态,…...