文档教程快速开始

快速入门

你可以通过以下方式在应用中使用 Loro:

可以借助 Loro Inspector 调试并可视化 Loro 文档的状态与历史。

下文示例统一使用 loro-crdt JavaScript 包。

Open in StackBlitz

安装

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 可能尚未完成初始化,从而导致页面静默失败。

解决方案:

  1. 移除事件监听器(大多数场景推荐):

    import {  } from "loro-crdt";
     
    const  = new ();
    // 在此编写逻辑…
  2. 在事件监听器内使用动态导入

    .("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!" }

容器

ListMapTreeMovableListText 等 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 的事件。

LoroTextLoroList 的事件以 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 容器的键。
   */
  : ;
}