快速入门
你可以通过以下方式在应用中使用 Loro:
loro-crdtNPM 包loroRust crateloro-swiftSwift 包loro-pyPython 包loro-cs社区维护的 C# 包- 也可以在 Loro 的 Deno 示例仓库中找到示例列表
可以借助 Loro Inspector 调试并可视化 Loro 文档的状态与历史。
下文示例统一使用 loro-crdt JavaScript 包。
安装
npm install loro-crdt
# 或者
pnpm install loro-crdt
# 或者
yarn add loro-crdt如果你使用的是 Vite,请在 vite.config.ts 中加入:
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
plugins: [...otherConfigures, wasm(), topLevelAwait()],
});⚠️ Vite 中的 DOMContentLoaded 定时问题
在 Vite 中使用 Loro 时,需要注意模块加载与 DOM 事件的时序问题。
问题: 下面的代码会导致页面没有任何渲染结果:
import { LoroDoc } from "loro-crdt";
document.addEventListener("DOMContentLoaded", () => {
const doc = new LoroDoc();
// 在此编写逻辑…
});原因: Vite 以异步方式加载 ES 模块,loro-crdt 内部的 WASM 初始化同样是异步的。当你在顶层导入模块却把逻辑放在 DOMContentLoaded 事件里执行时,事件触发时 WASM 可能尚未完成初始化,从而导致页面静默失败。
解决方案:
-
移除事件监听器(大多数场景推荐):
import { } from "loro-crdt"; const = new (); // 在此编写逻辑… -
在事件监听器内使用动态导入:
.("DOMContentLoaded", async () => { const { } = await import("loro-crdt"); const = new (); // 在此编写逻辑… });
动态导入可以确保模块及其 WASM 依赖加载完成后再执行代码。
如果你使用 Next.js,请在 next.config.js 中加入:
module.exports = {
webpack: function (config) {
config.experiments = {
layers: true,
asyncWebAssembly: true,
};
return config;
},
};你也可以通过 ESM 直接在浏览器中使用 Loro。下面是一个最小示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESM Module Example</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import init, {
LoroDoc,
} from "https://cdn.jsdelivr.net/npm/[email protected]/web/index.js";
init().then(() => {
const doc = new LoroDoc();
const text = doc.getText("text");
});
</script>
</body>
</html>简介
构建数据同步或实时协作应用众所周知是一项挑战,尤其当设备可能离线,或处于点对点网络时。Loro 可以帮你轻松应对这些问题。
当你用 Loro 来建模应用状态后,同步就会变得简单:
import { } from "loro-crdt";
const = new ();
const = new ();
// …在 docA 与 docB 上进行操作
// 假设 docA 与 docB 位于不同设备
const = .({ : "update" });
// 以任意方式把字节发送给 docB
.();
// docB 现在拥有 docA 的全部变更
const = .({ : "update" });
// 以任意方式把字节发送给 docA
.();
// docA 与 docB 现在同步,状态一致保存应用状态同样简单:
const = new ();
.("text").(0, "Hello world!");
const = .({ : "snapshot" });
// 这些字节可以写入本地存储、数据库或通过网络发送加载应用状态:
const = new ();
.();Loro 还支持时光旅行,让你为应用加入版本控制。进一步了解时光旅行功能。
.(); // 将文档检出到指定版本Loro 与 JSON schema 兼容。如果你的应用状态可以用 JSON 建模,大概率也能用 Loro 同步。由于需要遵循 JSON schema,Map 的键不得使用数字,也应避免循环引用。
.(); // 获取文档的 JSON 表示入口:LoroDoc
LoroDoc 是使用 Loro 的入口。要使用 Map、List、Text 等类型并进行数据同步,你必须先创建一个 Doc。
const = new ();
const : = .("text");
.(0, "Hello world!");
.(.()); // { "text": "Hello world!" }容器
List、Map、Tree、MovableList、Text 等 CRDT 类型统称为 Container。
以下示例展示了它们的基本操作:
const = new ();
const : = .("list");
.(0, "A");
.(1, "B");
.(2, "C");
const : = .("map");
// map 只能使用字符串键
.("key", "value");
(.()).({
: ["A", "B", "C"],
: { : "value" },
});
// 删除索引 0 处的两个元素
.(0, 2);
(.()).({
: ["C"],
: { : "value" },
});
// 在列表中插入一个文本容器
const = .(0, new ());
.(0, "Hello");
.(0, "Hi! ");
(.()).({
: ["Hi! Hello", "C"],
: { : "value" },
});
// 在 map 中插入一个列表容器
const = .("test", new ());
.(0, 1);
(.()).({
: ["Hi! Hello", "C"],
: { : "value", : [1] },
});保存与加载
Loro 是纯粹的库,不处理网络协议或存储机制。你需要自行保存和传输 Loro 导出的二进制数据。
要保存文档,可以使用 doc.export({ mode: "snapshot" }) 获取二进制表示;稍后通过 doc.import(data) 重新加载。
const = new ();
.("text").(0, "Hello world!");
const = .({ : "snapshot" });
const = new ();
.();
(.()).({
: "Hello world!",
});每次按键都导出完整文档效率不高。更好的做法是使用
doc.export({ mode: "update", from: VersionVector }) 获取自上次导出以来的增量操作。
const = new ();
.("text").(0, "Hello world!");
const = .({ : "snapshot" });
let = .();
.("text").(0, "✨");
const = .({ : "update", : });
= .();
.("text").(0, "😶🌫️");
const = .({ : "update", : });
{
/**
* 可以先导入快照,再导入增量,以恢复文档最新状态。
*/
// 导入快照
const = new ();
.();
(.()).({
: "Hello world!",
});
// 导入 update0
.();
(.()).({
: "✨Hello world!",
});
// 导入 update1
.();
(.()).({
: "😶🌫️✨Hello world!",
});
}
{
/**
* 也可以批量导入
*/
const = new ();
.([, , ]);
(.()).({
: "😶🌫️✨Hello world!",
});
}如果增量过多,可以重新导出一个快照,以提升导入速度并减小数据体积。
导出的二进制数据可以存储在任意位置,由你决定。
同步
两个存在并发编辑的文档,只需交换两次消息即可完成同步。
以下示例演示了两个文档之间的同步流程:
const = new ();
const = new ();
const : = .("list");
.(0, "A");
.(1, "B");
.(2, "C");
// B 导入来自 A 的操作
const : = .({ : "update" });
// 通过网络将数据发送给 B
.();
(.()).({
: ["A", "B", "C"],
});
const : = .("list");
.(1, 1);
// `doc.export({ mode: "update", from: version })` 会将某版本以来的所有操作打包
// `version` 即另一文档的版本向量
const = .({
: "update",
: .(),
});
.();
(.()).({
: ["A", "C"],
});
(.()).(.());事件
你可以订阅 Container 的事件。
LoroText 与 LoroList 的事件以 Quill Delta 格式呈现。
事件会在事务提交后触发。事务会在以下情况提交:
- 调用了
doc.commit()。 - 调用了
doc.export(mode)。 - 调用了
doc.import(data)。 - 调用了
doc.checkout(version)。
下面的示例展示了富文本事件:
// 代码改编自 https://github.com/loro-dev/loro-examples-deno
const = new ();
const = .("text");
.(0, "Hello world!");
.();
let = false;
.(() => {
for (const of .) {
if (.. === "text") {
(..).([
{
: 5,
: { : true },
},
]);
= true;
}
}
});
.({ : 0, : 5 }, "bold", true);
.();
await new (() => (, 1));
().();事件类型定义如下:
import { , , , } from "loro-crdt";
export interface LoroEventBatch {
/**
* 事件触发方式:
*
* - `local`:由本地事务触发。
* - `import`:由导入操作触发。
* - `checkout`:由 checkout 操作触发。
*/
: "local" | "import" | "checkout";
?: string;
/**
* 当前事件接收者的容器 ID。
* 如果订阅者是根文档,则为 undefined。
*/
?: ;
: LoroEvent[];
: ;
: ;
}
/**
* Loro 的具体事件。
*/
export interface LoroEvent {
/**
* 事件目标的容器 ID。
*/
: ;
: ;
/**
* 事件触发器的绝对路径,可以是列表容器的索引或 map 容器的键。
*/
: ;
}