text-version 是一个面向文本内容的轻量级版本管理工具,通过差异、快照和版本引用等方式压缩保存多个文本版本。它采用类似 Git 的思路,为 CMS、在线编辑器和配置文件等场景提供 commit、show、log、reset、squash 和导出等能力,并通过反向差异、最优差异存储、混合引用、分离式存储和自定义压缩优化存储空间与最新版本读取效率。
LOADING...
text-version 是一个面向文本内容的轻量级版本管理工具,通过差异、快照和版本引用等方式压缩保存多个文本版本。它采用类似 Git 的思路,为 CMS、在线编辑器和配置文件等场景提供 commit、show、log、reset、squash 和导出等能力,并通过反向差异、最优差异存储、混合引用、分离式存储和自定义压缩优化存储空间与最新版本读取效率。
Git 在我心目中,是当之无愧的最佳文本版本管理工具。
它可以追踪版本、保存提交信息、查看历史差异,还可以通过增量的方式减少重复存储。对于代码来说,Git 基本已经是标准答案了。
最近(其实是半年前)我在重写 CMS 系统,需要给文章增加版本管理功能。最简单的方式当然是每次修改文章时,直接把完整内容保存到数据库里,再额外记录版本号和修改时间。不过这样做有一个比较明显的问题:每个版本都保存了一份完整文本。文章比较短的时候没什么问题,但如果文章数量和版本数量都增加了,数据库中就会出现大量重复内容。
所以我写了一个 NPM 库,尝试使用类似 Git 的方式管理文本版本:
text-version 是一个针对文本内容的轻量级版本管理工具。它不负责管理文件,也不尝试替代 Git,而是把多个文本版本压缩成一个字符串,方便直接存进数据库、localStorage 或其他存储系统。
目前主要支持以下功能:
commit:提交新版本show:查看指定版本log:查看版本历史latest:获取最新版本reset:重置到指定版本,并删除之后的版本squash:删除指定版本之前的历史,减少存储空间export:导出版本数据或者:
ES Module:
CommonJS:
第二次和第三次提交没有什么特殊的地方,和普通的 commit 类似。第三次没有传版本名,所以库会根据文本内容生成一个版本名。
如果手动指定的版本名已经存在,库会自动在后面追加 #,直到生成一个没有重复的版本名:
输出类似这样:
这里的 isSnapshot 表示这个版本是否是完整快照,当前策略下,普通提交之后,最新版本通常是快照,之前的版本则保存为差异。启用最优差异存储后,中间也可能保留额外的快照,作为历史版本的存储锚点。
如果版本不存在,会返回 null:
由于非重复提交的最新版本通常直接保存为快照,所以读取最新版本不需要从第一个版本开始依次应用差异。
如果最新版本的内容和历史版本完全相同,则会使用版本引用。这种情况下 latest() 会自动解析引用,不影响使用。
版本数据由实例内部管理,但最终还是要保存到数据库或其他地方。可以使用 export 导出:
输出大致如下:
之后可以把这个字符串保存到数据库:
恢复时,再把保存的数据传给构造函数:
例如在浏览器中,也可以直接使用 localStorage:
text-version 内部使用长度前缀格式保存数据。快照版本的格式是:
差异版本的格式是:
差异操作主要有三种:
R<数字>:保留指定数量的字符I<长度>:<文本>:插入文本D<数字>:删除指定数量的字符例如:
可以理解为对当前文本执行:
文本差异由 计算,之后再转换成 自己的操作格式。
旧版(v1)使用的是正向增量:
这种方式有一个问题,如果想获取最新版本,就需要从 v1 开始,依次应用 v2、v3 的差异。版本越多,读取最新版本需要做的工作就越多。
现在(v2)的基础策略改成了反向差异:
也就是说,最新版本直接保存完整内容,历史版本保存从后往前还原的差异;读取最新版本时,直接读取最后一个快照即可。
读取历史版本时,则从目标版本向后寻找最近的快照,再向前应用反向差异。这样做是因为最优差异存储可能保留多个快照,如果直接跨到最后一个快照,反而可能跨过中间的存储边界。
另外,代码中还增加了缓存:
所以同一个版本重复读取时,不需要每次都重新解析全部数据。
默认会比较多种存储方式:
库比较的不是单独某段差异字符串的长度,而是把候选方案放回完整的版本存储中,计算最终的整体长度。这样如果某个混合引用需要把被引用的版本提升为独立快照,这部分额外成本也会计算进去。下面是测试中使用的一种场景:
在第三次提交之后,v2 可能使用这样的混合引用:
这里的 表示先读取 v1,再删除开头的 20 个字符,从而得到 v2。
由于 v1 原来可能依赖后续版本的反向差异,直接引用它有形成循环依赖的风险。因此当前实现会在需要时把 v1 提升为独立快照,再让 v2 引用它,最终库会在这些候选方案中选择完整存储结果更短的一种。
默认情况下, 是开启的;如果需要做基准对比,可以关闭它:
关闭之后,提交时只使用相邻版本的反向差异,不再尝试混合引用和其他存储优化方式。
这个选项主要用于性能测试,普通使用没有必要关闭。
如果连续提交了完全相同的文本,继续保存差异也没有意义。
例如:
第二个版本会使用引用:
表示 v2 的内容直接引用 v1。
查看 v2 时,库会自动解析引用:
这里的判断是完全相同的字符串比较,并不是相似文本检测。
会保留目标版本以及它之前的版本,同时删除目标版本之后的版本。
重置之后,v2 会成为最新版本,因此会重新构建为快照。
如果目标版本不存在,会抛出错误:
用来删除目标版本之前的历史。
这个操作是不可逆的,v1 被删除后就不能再恢复。
如果 之后只剩下 v2,那么 v2 会是最新快照。但如果 v3、v4 这些后续版本还在,当前实现会重新以最后一个版本作为快照,v2 和中间版本保存为差异。
所以更准确地说, 是“删除目标版本之前的历史并重新整理存储”,并不是永远把目标版本设置成快照。
如果最新版本内容很大,直接把完整内容和版本元数据放在同一个字符串中,使用起来不太灵活。
这时可以使用分离式导出:
返回值是:
中保存版本列表和差异数据,最新版本的完整内容会被替换为一个占位符; 则是最新版本的完整文本。
这样就可以分别保存:
恢复时,需要同时传入 metadata 和 snapshot:
分离式存储比较适合这些场景:
在导入分离数据时,库会计算 snapshot 的哈希,并和 metadata 中的占位符进行比较,用于完整性校验。
如果 snapshot 被修改,哈希不匹配,就会抛出错误。
Text-Version 提供了一个简单的压缩接口:
例如可以用 Base64 做一个简单演示:
实际使用时,当然不建议把 Base64 当作压缩算法。这里主要是为了展示接口怎么接入,你可以根据项目情况接入 gzip、brotli 或其他压缩方案。
如果使用分离式存储,metadata 也会经过压缩提供者处理,但 snapshot 仍然是单独返回的原始文本:
除了 NPM,也可以直接在浏览器中使用 UMD 构建版本。
需要先引入 ,再引入 Text-Version:
也可以使用 unpkg:
引入之后,可以通过 创建实例:
普通存储:
分离式存储:
如果不使用压缩提供者,也可以把 options 作为第三个参数传入:
、 和 返回当前实例,可以进行链式调用。
不是 Git 的替代品,它做的事情非常简单:把一串文本的多个历史版本压缩保存起来,然后提供提交、查看、回滚和导出功能。对于 CMS 文章版本、配置文件、在线编辑器这类场景来说,这个库应当还是相当好用的。
不过历史版本越多,读取较早版本时需要还原的差异也越多。因此如果版本长期增长,建议定期使用 清理旧历史。
| 方法 | 说明 |
|---|
| 提交新版本,版本名为空时自动生成 | |
| 查看指定版本,不存在时返回 | |
| 获取版本历史 | |
| 获取最新版本内容 | |
| 删除指定版本之后的版本 | |
| 删除指定版本之前的版本 | |
| 导出完整存储字符串 | |
| 分离导出 metadata 和 snapshot |
diff-match-patchtext-version=v1:D20optimizeDiffStorage=v1resetsquashsquash("v2")squashmetadatasnapshotdiff-match-patchwindow.TextVersion.TextVersioncommitresetsquashtext-versionsquashcommit(text, version?)show(version)nulllog()latest()reset(version)squash(version)export()export("separate")v1 → v1# → v1##2:v1:R6D7
2:v2:R3D10I2:世界
:6:ycdf93:你好,TypeScript!\n这是第二行。:版本名长度:版本名:内容版本名长度:版本名:操作序列2:v1:R6D7v1:完整快照
v2:从 v1 变成 v2 的差异
v3:从 v2 变成 v3 的差异v1:从 v2 还原到 v1 的差异
v2:从 v3 还原到 v2 的差异
v3:最新版本的完整快照:2:v1:AAAAAAAA...BBBBBBBB...
2:v2:=v1:D20
:2:v3:C:2:v1:相同内容
2:v2:=v1npm install text-versionpnpm add text-versionyarn add text-versionimport { TextVersion } from "text-version";const { TextVersion } = require("text-version");const tv = new TextVersion();const tv = new TextVersion();
tv.commit("你好,世界!", "v1");
tv.commit("你好,世界!\n这是第二行。", "v2");
tv.commit("你好,TypeScript!\n这是第二行。");console.log(tv.log());[
{ version: "v1", isSnapshot: false },
{ version: "v2", isSnapshot: false },
{ version: "ycdf93", isSnapshot: true }
]console.log(tv.show("v1"));
// 你好,世界!console.log(tv.show("not-found"));
// nullconsole.log(tv.latest());
// 你好,TypeScript!
// 这是第二行。const storage = tv.export();
console.log(storage);await db.article.update({
id: articleId,
versionData: tv.export(),
});const storage = await db.article.getVersionData(articleId);
const restored = new TextVersion(storage);
console.log(restored.latest());
console.log(restored.show("v1"));const tv = new TextVersion();
tv.commit("第一版内容", "v1");
tv.commit("第二版内容", "v2");
localStorage.setItem("article-version", tv.export());
const savedStorage = localStorage.getItem("article-version");
const restored = new TextVersion(savedStorage || "");
console.log(restored.latest());const v1 = "A".repeat(20) + "B".repeat(160);
const v2 = "B".repeat(160);
const v3 = "C";
const tv = new TextVersion();
tv.commit(v1, "v1");
tv.commit(v2, "v2");
tv.commit(v3, "v3");const baseline = new TextVersion(
undefined,
undefined,
{ optimizeDiffStorage: false },
);const tv = new TextVersion();
tv.commit("相同内容", "v1");
tv.commit("相同内容", "v2");
console.log(tv.export());console.log(tv.show("v2"));
// 相同内容const tv = new TextVersion();
tv.commit("版本 1", "v1");
tv.commit("版本 2", "v2");
tv.commit("版本 3", "v3");
tv.reset("v2");
console.log(tv.log());
// v1、v2
console.log(tv.show("v3"));
// nulltv.reset("not-found");
// Error: 版本 not-found 不存在const tv = new TextVersion();
tv.commit("版本 1", "v1");
tv.commit("版本 2", "v2");
tv.commit("版本 3", "v3");
tv.squash("v2");
console.log(tv.log());
// v2、v3
console.log(tv.show("v1"));
// null
console.log(tv.show("v2"));
// 版本 2const tv = new TextVersion();
tv.commit("第一个版本", "v1");
tv.commit("第二个版本", "v2");
tv.commit("这是一个比较长的最新版本内容...", "v3");
const result = tv.export("separate");
console.log(result.metadata);
console.log(result.snapshot);{
metadata: "...##[[00ycdf93]]##",
snapshot: "这是一个比较长的最新版本内容..."
}const { metadata, snapshot } = tv.export("separate");
await db.article.update({
id: articleId,
versionMetadata: metadata,
latestSnapshot: snapshot,
});const restored = new TextVersion(metadata, snapshot);
console.log(restored.latest());
console.log(restored.show("v1"));const compressionProvider = {
compress(data) {
return Buffer.from(data).toString("base64");
},
decompress(data) {
return Buffer.from(data, "base64").toString();
},
};
const tv = new TextVersion("", compressionProvider);
tv.commit("测试内容", "v1");
tv.commit("更多测试内容", "v2");
const storage = tv.export();
const restored = new TextVersion(
storage,
compressionProvider,
);
console.log(restored.latest());
// 更多测试内容const result = tv.export("separate");
const restored = new TextVersion(
result.metadata,
result.snapshot,
compressionProvider,
);const tv = new TextVersion(
undefined,
undefined,
{ optimizeDiffStorage: false },
);interface CompressionProvider {
compress(data: string): string;
decompress(data: string): string;
}new TextVersion(
initialStorage?: string,
compressionProvider?: CompressionProvider,
options?: TextVersionOptions,
)new TextVersion(
metadata: string,
snapshot: string,
compressionProvider?: CompressionProvider,
options?: TextVersionOptions,
)interface VersionInfo {
version: string;
isSnapshot: boolean;
}
interface CompressionProvider {
compress(data: string): string;
decompress(data: string): string;
}
interface TextVersionOptions {
optimizeDiffStorage?: boolean;
}
interface DiffOperation {
type: "retain" | "insert" | "delete";
length?: number;
text?: string;
}<script src="https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"></script>
<script src="https://cdn.jsdelivr.net/npm/text-version/dist/index.umd.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"></script>
<script src="https://unpkg.com/text-version/dist/index.umd.js"></script><script>
const tv = new window.TextVersion.TextVersion();
tv.commit("第一版内容", "v1");
tv.commit("第二版内容", "v2");
console.log(tv.latest());
console.log(tv.log());
</script>
评论