普通视图
Fork 主题如何更新?基于 Ink 构建主题更新 CLI 工具
本文地址:blog.cosine.ren/post/intera…
本文图表、伪代码等由 AI 辅助编写
背景
当你 fork 了一个开源项目作为自己的博客主题,如何优雅地从上游仓库同步更新?手动敲一串 Git 命令既繁琐又容易出错;但直接点 Fork 的 Sync 按钮,又可能覆盖你的自定义配置和内容。
很多人因此在「保持更新」和「保留修改」之间左右为难:要么干脆二开后不再同步,要么每次更新都提心吊胆。
这也是为什么不少项目会像 @fumadocs/cli 一样,提供专门的 CLI 来完成更新等相关操作。
本文将介绍如何简单地构建一个交互式 CLI 工具,把 fork 同步的流程自动化起来。
这个工具的核心目标是:
- 安全:更新前检查工作区状态,必要时可备份
- 透明:预览所有变更,让用户决定是否更新
- 友好:出现冲突时给出明确指引
具体的代码可以看这个 PR:
不过这个 PR 只是最初的版本,后面又缝缝补补了不少东西,整体流程是我研究一个周末后摸索出的,如有不足,那一定是我考虑不周,欢迎指出~
在这个 PR 里,我基于 Ink 构建了一个交互式 TUI 工具,提供了博客内容备份/还原、主题更新、内容生成、备份管理等功能:
pnpm koharu # 交互式主菜单
pnpm koharu backup # 备份博客内容 (--full 完整备份)
pnpm koharu restore # 还原备份 (--latest, --dry-run, --force)
pnpm koharu update # 从上游同步更新 (--check, --skip-backup, --force)
pnpm koharu generate # 生成内容资产 (LQIP, 相似度, AI 摘要)
pnpm koharu clean # 清理旧备份 (--keep N)
pnpm koharu list # 查看所有备份
![]()
其中备份功能可以:
- 基础备份:博客文章、配置、头像、.env
- 完整备份:包含所有图片和生成的资产文件
- 自动生成
manifest.json记录主题版本与备份元信息(时间等)
还原功能可以:
- 交互式选择备份文件
- 支持
--dry-run预览模式 - 显示备份类型、版本、时间等元信息
主题更新功能可以:
- 自动配置 upstream remote 指向原始仓库
- 预览待合并的提交列表(显示 hash、message、时间)
- 更新前可选备份,支持冲突检测与处理
- 合并成功后自动安装依赖
- 支持
--check仅检查更新、--force跳过工作区检查
整体架构
infographic sequence-snake-steps-underline-text
data
title Git Update 命令流程
desc 从 upstream 同步更新的完整工作流
items
- label 检查状态
desc 验证当前分支和工作区状态
icon mdi/source-branch-check
- label 配置远程
desc 确保 upstream remote 已配置
icon mdi/source-repository
- label 获取更新
desc 从 upstream 拉取最新提交
icon mdi/cloud-download
- label 预览变更
desc 显示待合并的提交列表
icon mdi/file-find
- label 确认备份
desc 可选:备份当前内容
icon mdi/backup-restore
- label 执行合并
desc 合并 upstream 分支到本地
icon mdi/merge
- label 处理结果
desc 成功则安装依赖,冲突则提示解决
icon mdi/check-circle
更新相关 Git 命令详解
1. 检查当前分支
git rev-parse --abbrev-ref HEAD
作用:获取当前所在分支的名称。
参数解析:
-
rev-parse:解析 Git 引用 -
--abbrev-ref:输出简短的引用名称(如main),而不是完整的 SHA
使用场景:确保用户在正确的分支(如 main)上执行更新,避免在 feature 分支上意外合并上游代码。
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD")
.toString()
.trim();
if (currentBranch !== "main") {
throw new Error(`仅支持在 main 分支执行更新,当前分支: ${currentBranch}`);
}
2. 检查工作区状态
git status --porcelain
作用:以机器可读的格式输出工作区状态。
参数解析:
-
--porcelain:输出稳定、易于解析的格式,不受 Git 版本和语言设置影响
输出格式:
M modified-file.ts # 已暂存的修改
M unstaged-file.ts # 未暂存的修改
?? untracked-file.ts # 未跟踪的文件
A new-file.ts # 新添加的文件
D deleted-file.ts # 删除的文件
前两个字符分别表示暂存区和工作区的状态。
const statusOutput = execSync("git status --porcelain").toString();
const uncommittedFiles = statusOutput.split("\n").filter((line) => line.trim());
const isClean = uncommittedFiles.length === 0;
3. 管理远程仓库
检查 remote 是否存在
git remote get-url upstream
作用:获取指定 remote 的 URL,如果不存在会报错。
添加 upstream remote
# 将 URL 替换为你的上游仓库地址
git remote add upstream https://github.com/original/repo.git
作用:添加一个名为 upstream 的远程仓库,指向原始项目。
为什么需要 upstream?
当你 fork 一个项目后,你的 origin 指向你自己的 fork,而 upstream 指向原始项目。这样可以:
- 从
upstream拉取原项目的更新 - 向
origin推送你的修改
// UPSTREAM_URL 需替换为你的上游仓库地址
const UPSTREAM_URL = "https://github.com/original/repo.git";
function ensureUpstreamRemote(): string {
try {
return execSync("git remote get-url upstream").toString().trim();
} catch {
execSync(`git remote add upstream ${UPSTREAM_URL}`);
return UPSTREAM_URL;
}
}
4. 获取远程更新
git fetch upstream
作用:从 upstream 远程仓库下载所有分支的最新提交,但不会自动合并到本地分支。
与 git pull 的区别:
-
fetch只下载数据,不修改本地代码 -
pull=fetch+merge,会自动合并
使用 fetch 可以让我们先预览变更,再决定是否合并。
5. 计算提交差异
git rev-list --left-right --count HEAD...upstream/main
作用:计算本地分支与 upstream/main 之间的提交差异。
参数解析:
-
rev-list:列出提交记录 -
--left-right:区分左侧(本地)和右侧(远程)的提交 -
--count:只输出计数,不列出具体提交 -
HEAD...upstream/main:三个点表示对称差集
输出示例:
2 5
表示本地有 2 个提交不在 upstream 上(ahead),upstream 有 5 个提交不在本地(behind)。
const revList = execSync(
"git rev-list --left-right --count HEAD...upstream/main"
)
.toString()
.trim();
const [aheadStr, behindStr] = revList.split("\t");
const aheadCount = parseInt(aheadStr, 10);
const behindCount = parseInt(behindStr, 10);
console.log(`本地领先 ${aheadCount} 个提交,落后 ${behindCount} 个提交`);
6. 查看待合并的提交
git log HEAD..upstream/main --pretty=format:"%h|%s|%ar|%an" --no-merges
作用:列出 upstream/main 上有但本地没有的提交。
参数解析:
-
HEAD..upstream/main:两个点表示 A 到 B 的差集(B 有而 A 没有的) -
--pretty=format:"...":自定义输出格式-
%h:短 hash -
%s:提交信息 -
%ar:相对时间(如 "2 days ago") -
%an:作者名
-
-
--no-merges:排除 merge commit
输出示例:
a1b2c3d|feat: add dark mode|2 days ago|Author Name
e4f5g6h|fix: typo in readme|3 days ago|Author Name
const commitFormat = "%h|%s|%ar|%an";
const output = execSync(
`git log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
).toString();
const commits = output
.split("\n")
.filter(Boolean)
.map((line) => {
const [hash, message, date, author] = line.split("|");
return { hash, message, date, author };
});
7. 查看远程文件内容
git show upstream/main:package.json
作用:直接查看远程分支上某个文件的内容,无需切换分支或合并。
使用场景:获取上游仓库的版本号,用于显示"将更新到 x.x.x 版本"。
const packageJson = execSync("git show upstream/main:package.json").toString();
const { version } = JSON.parse(packageJson);
console.log(`最新版本: ${version}`);
8. 执行合并
git merge upstream/main --no-edit
作用:将 upstream/main 分支合并到当前分支。
参数解析:
-
--no-edit:使用自动生成的合并提交信息,不打开编辑器
合并策略:Git 会自动选择合适的合并策略:
- Fast-forward:如果本地没有新提交,直接移动指针
- Three-way merge:如果有分叉,创建一个合并提交
注意:本工具采用 merge 同步上游,保留本地历史。如果你的需求是"强制与上游一致"(丢弃本地修改),需要使用 rebase 或 reset 方案,不在本文讨论范围。
9. 检测合并冲突
git diff --name-only --diff-filter=U
作用:列出所有未解决冲突的文件。
参数解析:
-
--name-only:只输出文件名 -
--diff-filter=U:只显示 Unmerged(未合并/冲突)的文件
另一种方式是解析 git status --porcelain 的输出,查找冲突标记:
const statusOutput = execSync("git status --porcelain").toString();
const conflictFiles = statusOutput
.split("\n")
.filter((line) => {
const status = line.slice(0, 2);
// U = Unmerged, AA = both added, DD = both deleted
return status.includes("U") || status === "AA" || status === "DD";
})
// 注:为简化展示,这里直接截取路径
// 若需完整兼容重命名/特殊路径,应使用更严格的 porcelain 解析
.map((line) => line.slice(3).trim());
10. 中止合并
git merge --abort
作用:中止当前的合并操作,恢复到合并前的状态。
使用场景:当用户遇到冲突但不想手动解决时,可以选择中止合并。
function abortMerge(): boolean {
try {
execSync("git merge --abort");
return true;
} catch {
return false;
}
}
状态机设计
如果是简单粗暴的使用 useEffect 的话,会出现很多 useEffect 那自然很不好。
整个更新流程使用简单的 useReducer + Effect Map 模式管理,将状态转换逻辑和副作用处理分离,确保流程清晰可控。
为什么不用 Redux?
在设计 CLI 状态管理时,很自然会想到 Redux,毕竟它是 React 生态中最成熟的状态管理方案,而且还是用着 Ink 来进行开发的。但对于 CLI 工具,useReducer 是更合适的选择,理由如下:
- 状态作用域单一:CLI 工具通常是单组件树结构,不存在跨页面、跨路由的状态共享需求,
- 无需 Middleware 生态:Redux 的强大之处在于中间件生态(redux-thunk、redux-saga、redux-observable),用于处理复杂的异步流程。但我们的场景不需要那么复杂。
- 依赖最小化:CLI 工具应该快速启动、轻量运行。
useReducer内置于 React,不会引入额外依赖(当然 React 本身也是依赖,不过我的项目里本来就需要它)
总之,对这个场景来说 Redux 有点"过度设计"。
那咋整?
- Reducer:集中管理所有状态转换逻辑,纯函数易于测试
- Effect Map:状态到副作用的映射,统一处理异步操作
-
单一 Effect:一个
useEffect驱动整个流程
下面是完整的状态转换流程图,展示了所有可能的状态转换路径和条件分支:
注意:Mermaid stateDiagram 中状态名不能包含连字符
-,这里使用 camelCase 命名。
stateDiagram-v2
[*] --> checking: 开始更新
checking --> error: 不在 main 分支
checking --> dirtyWarning: 工作区不干净 && !force
checking --> fetching: 工作区干净 || force
dirtyWarning --> [*]: 用户取消
dirtyWarning --> fetching: 用户继续
fetching --> upToDate: behindCount = 0
fetching --> backupConfirm: behindCount > 0 && !skipBackup
fetching --> preview: behindCount > 0 && skipBackup
backupConfirm --> backingUp: 用户确认备份
backupConfirm --> preview: 用户跳过备份
backingUp --> preview: 备份完成
backingUp --> error: 备份失败
preview --> [*]: checkOnly 模式
preview --> merging: 用户确认更新
preview --> [*]: 用户取消
merging --> conflict: 合并冲突
merging --> installing: 合并成功
conflict --> [*]: 用户处理冲突
installing --> done: 依赖安装成功
installing --> error: 依赖安装失败
done --> [*]
error --> [*]
upToDate --> [*]
类型定义
// 12 种状态覆盖完整流程
type UpdateStatus =
| "checking" // 检查 Git 状态
| "dirty-warning" // 工作区有未提交更改
| "backup-confirm" // 确认备份
| "backing-up" // 正在备份
| "fetching" // 获取更新
| "preview" // 显示更新预览
| "merging" // 合并中
| "installing" // 安装依赖
| "done" // 完成
| "conflict" // 有冲突
| "up-to-date" // 已是最新
| "error"; // 错误
// Action 驱动状态转换
type UpdateAction =
| { type: "GIT_CHECKED"; payload: GitStatusInfo }
| { type: "FETCHED"; payload: UpdateInfo }
| { type: "BACKUP_CONFIRM" | "BACKUP_SKIP" | "UPDATE_CONFIRM" | "INSTALLED" }
| { type: "BACKUP_DONE"; backupFile: string }
| { type: "MERGED"; payload: MergeResult }
| { type: "ERROR"; error: string };
Reducer 集中状态转换
所有状态转换逻辑集中在 reducer 中,每个 case 只处理当前状态下合法的 action:
function updateReducer(state: UpdateState, action: UpdateAction): UpdateState {
const { status, options } = state;
// 通用错误处理:任何状态都可以转到 error
if (action.type === "ERROR") {
return { ...state, status: "error", error: action.error };
}
switch (status) {
case "checking": {
if (action.type !== "GIT_CHECKED") return state;
const { payload: gitStatus } = action;
if (gitStatus.currentBranch !== "main") {
return {
...state,
status: "error",
error: "仅支持在 main 分支执行更新",
};
}
if (!gitStatus.isClean && !options.force) {
return { ...state, status: "dirty-warning", gitStatus };
}
return { ...state, status: "fetching", gitStatus };
}
case "fetching": {
if (action.type !== "FETCHED") return state;
const { payload: updateInfo } = action;
if (updateInfo.behindCount === 0) {
return { ...state, status: "up-to-date", updateInfo };
}
const nextStatus = options.skipBackup ? "preview" : "backup-confirm";
return { ...state, status: nextStatus, updateInfo };
}
// ... 其他状态处理
}
}
Effect Map:统一副作用处理
每个需要执行副作用的状态对应一个 effect 函数,可返回 cleanup 函数:
type EffectFn = (
state: UpdateState,
dispatch: Dispatch<UpdateAction>
) => (() => void) | undefined;
const statusEffects: Partial<Record<UpdateStatus, EffectFn>> = {
checking: (_state, dispatch) => {
const gitStatus = checkGitStatus();
ensureUpstreamRemote();
dispatch({ type: "GIT_CHECKED", payload: gitStatus });
return undefined;
},
fetching: (_state, dispatch) => {
fetchUpstream();
const info = getUpdateInfo();
dispatch({ type: "FETCHED", payload: info });
return undefined;
},
installing: (_state, dispatch) => {
let cancelled = false;
installDeps().then((result) => {
if (cancelled) return;
dispatch(
result.success
? { type: "INSTALLED" }
: { type: "ERROR", error: result.error }
);
});
return () => {
cancelled = true;
}; // cleanup
},
};
组件使用
组件中只需一个核心 useEffect 来驱动整个状态机:
function UpdateApp({ checkOnly, skipBackup, force }) {
const [state, dispatch] = useReducer(
updateReducer,
{ checkOnly, skipBackup, force },
createInitialState
);
// 核心:单一 effect 处理所有副作用
useEffect(() => {
const effect = statusEffects[state.status];
if (!effect) return;
return effect(state, dispatch);
}, [state.status, state]);
// UI 渲染基于 state.status
return <Box>...</Box>;
}
这种模式的优势:
- 可测试性:Reducer 是纯函数,可以独立测试状态转换
-
可维护性:状态逻辑集中,不会分散在多个
useEffect中 - 可扩展性:添加新状态只需在 reducer 和 effect map 各加一个 case
用户交互设计
使用 React Ink 构建终端 UI,提供友好的交互体验:
预览更新
发现 5 个新提交:
a1b2c3d feat: add dark mode (2 days ago)
e4f5g6h fix: responsive layout (3 days ago)
i7j8k9l docs: update readme (1 week ago)
... 还有 2 个提交
注意: 本地有 1 个未推送的提交
确认更新到最新版本? (Y/n)
处理冲突
发现合并冲突
冲突文件:
- src/config.ts
- src/components/Header.tsx
你可以:
1. 手动解决冲突后运行: git add . && git commit
2. 中止合并恢复到更新前状态
备份文件: backup-2026-01-10-full.tar.gz
是否中止合并? (Y/n)
完整代码实现
Git 操作封装
import { execSync } from "node:child_process";
function git(args: string): string {
return execSync(`git ${args}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
}
function gitSafe(args: string): string | null {
try {
return git(args);
} catch {
return null;
}
}
export function checkGitStatus(): GitStatusInfo {
const currentBranch = git("rev-parse --abbrev-ref HEAD");
const statusOutput = gitSafe("status --porcelain") || "";
const uncommittedFiles = statusOutput
.split("\n")
.filter((line) => line.trim());
return {
currentBranch,
isClean: uncommittedFiles.length === 0,
// 注:简化处理,完整兼容需更严格的 porcelain 解析
uncommittedFiles: uncommittedFiles.map((line) => line.slice(3).trim()),
};
}
export function getUpdateInfo(): UpdateInfo {
const revList =
gitSafe("rev-list --left-right --count HEAD...upstream/main") || "0\t0";
const [aheadStr, behindStr] = revList.split("\t");
const commitFormat = "%h|%s|%ar|%an";
const commitsOutput =
gitSafe(
`log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
) || "";
const commits = commitsOutput
.split("\n")
.filter(Boolean)
.map((line) => {
const [hash, message, date, author] = line.split("|");
return { hash, message, date, author };
});
return {
behindCount: parseInt(behindStr, 10),
aheadCount: parseInt(aheadStr, 10),
commits,
};
}
export function mergeUpstream(): MergeResult {
try {
git("merge upstream/main --no-edit");
return { success: true, hasConflict: false, conflictFiles: [] };
} catch {
const conflictFiles = getConflictFiles();
return {
success: false,
hasConflict: conflictFiles.length > 0,
conflictFiles,
};
}
}
function getConflictFiles(): string[] {
const output = gitSafe("diff --name-only --diff-filter=U") || "";
return output.split("\n").filter(Boolean);
}
Git 命令速查表
| 命令 | 作用 | 场景 |
|---|---|---|
git rev-parse --abbrev-ref HEAD |
获取当前分支名 | 验证分支 |
git status --porcelain |
机器可读的状态输出 | 检查工作区 |
git remote get-url <name> |
获取 remote URL | 检查 remote |
git remote add <name> <url> |
添加 remote | 配置 upstream |
git fetch <remote> |
下载远程更新 | 获取更新 |
git rev-list --left-right --count A...B |
统计差异提交数 | 计算 ahead/behind |
git log A..B --pretty=format:"..." |
列出差异提交 | 预览更新 |
git show <ref>:<path> |
查看远程文件 | 获取版本号 |
git merge <branch> --no-edit |
自动合并 | 执行更新 |
git diff --name-only --diff-filter=U |
列出冲突文件 | 检测冲突 |
git merge --abort |
中止合并 | 回滚操作 |
Git 命令功能分类
为了更好地理解这些命令的用途,下面按功能将它们分类展示:
infographic hierarchy-structure
data
title Git 命令功能分类
desc 按操作类型组织的命令清单
items
- label 状态检查
icon mdi/information
children
- label git rev-parse
desc 获取当前分支名
- label git status --porcelain
desc 检查工作区状态
- label 远程管理
icon mdi/server-network
children
- label git remote get-url
desc 检查 remote 是否存在
- label git remote add
desc 添加 upstream remote
- label git fetch
desc 下载远程更新
- label 提交分析
icon mdi/source-commit
children
- label git rev-list
desc 统计提交差异
- label git log
desc 查看提交历史
- label git show
desc 查看远程文件内容
- label 合并操作
icon mdi/source-merge
children
- label git merge
desc 执行分支合并
- label git merge --abort
desc 中止合并恢复状态
- label 冲突检测
icon mdi/alert-octagon
children
- label git diff --diff-filter=U
desc 列出未解决冲突文件
备份还原功能实现
除了主题更新,CLI 还提供了完整的备份还原功能,确保用户数据安全。
备份和还原是两个互补的操作,下图展示了它们的完整工作流:
infographic compare-hierarchy-row-letter-card-compact-card
data
title 备份与还原流程对比
desc 两个互补操作的完整工作流
items
- label 备份流程
icon mdi/backup-restore
children
- label 检查配置
desc 确定备份类型和范围
- label 创建临时目录
desc 准备暂存空间
- label 复制文件
desc 按配置复制所需文件
- label 生成 manifest
desc 记录版本和元信息
- label 压缩打包
desc tar.gz 压缩存档
- label 清理临时目录
desc 删除暂存目录
- label 还原流程
icon mdi/restore
children
- label 选择备份
desc 读取 manifest 显示备份信息
- label 解压到临时目录
desc 提取归档内容(包含 manifest)
- label 读取 manifest.files
desc 获取实际备份成功的文件列表
- label 按映射复制文件
desc 使用自动生成的 RESTORE_MAP
- label 清理临时目录
desc 删除解压的暂存文件
备份项配置
备份系统采用配置驱动的方式,定义需要备份的文件和目录:
export interface BackupItem {
src: string; // 源路径(相对于项目根目录)
dest: string; // 备份内目标路径
label: string; // 显示标签
required: boolean; // 是否为必需项(basic 模式包含)
}
export const BACKUP_ITEMS: BackupItem[] = [
// 基础备份项(required: true)
{
src: "src/content/blog",
dest: "content/blog",
label: "博客文章",
required: true,
},
{
src: "config/site.yaml",
dest: "config/site.yaml",
label: "网站配置",
required: true,
},
{
src: "src/pages/about.md",
dest: "pages/about.md",
label: "关于页面",
required: true,
},
{
src: "public/img/avatar.webp",
dest: "img/avatar.webp",
label: "用户头像",
required: true,
},
{ src: ".env", dest: "env", label: "环境变量", required: true },
// 完整备份额外项目(required: false)
{ src: "public/img", dest: "img", label: "所有图片", required: false },
{
src: "src/assets/lqips.json",
dest: "assets/lqips.json",
label: "LQIP 数据",
required: false,
},
{
src: "src/assets/similarities.json",
dest: "assets/similarities.json",
label: "相似度数据",
required: false,
},
{
src: "src/assets/summaries.json",
dest: "assets/summaries.json",
label: "AI 摘要数据",
required: false,
},
];
备份流程
备份操作使用 tar.gz 格式压缩,并生成 manifest.json 记录元信息:
export function runBackup(
isFullBackup: boolean,
onProgress?: (results: BackupResult[]) => void
): BackupOutput {
// 1. 创建备份目录和临时目录
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const tempDir = path.join(BACKUP_DIR, `.tmp-backup-${timestamp}`);
// 2. 过滤备份项目(基础备份只包含 required: true 的项目)
const itemsToBackup = BACKUP_ITEMS.filter(
(item) => item.required || isFullBackup
);
// 3. 复制文件到临时目录
const results: BackupResult[] = [];
for (const item of itemsToBackup) {
const srcPath = path.join(PROJECT_ROOT, item.src);
const destPath = path.join(tempDir, item.dest);
if (fs.existsSync(srcPath)) {
fs.cpSync(srcPath, destPath, { recursive: true });
results.push({ item, success: true, skipped: false });
} else {
results.push({ item, success: false, skipped: true });
}
onProgress?.([...results]); // 进度回调
}
// 4. 生成 manifest.json
const manifest = {
name: "astro-koharu-backup",
version: getVersion(),
type: isFullBackup ? "full" : "basic",
timestamp,
created_at: new Date().toISOString(),
files: Object.fromEntries(results.map((r) => [r.item.dest, r.success])),
};
fs.writeFileSync(
path.join(tempDir, "manifest.json"),
JSON.stringify(manifest, null, 2)
);
// 5. 压缩并清理
tarCreate(backupFilePath, tempDir);
fs.rmSync(tempDir, { recursive: true, force: true });
return { results, backupFile: backupFilePath, fileSize, timestamp };
}
tar 操作封装
使用系统 tar 命令进行压缩和解压,并添加路径遍历安全检查:
// 安全验证:防止路径遍历攻击
function validateTarEntries(entries: string[], archivePath: string): void {
for (const entry of entries) {
if (entry.includes("\0")) {
throw new Error(`tar entry contains null byte`);
}
const normalized = path.posix.normalize(entry);
if (path.posix.isAbsolute(normalized)) {
throw new Error(`tar entry is absolute path: ${entry}`);
}
if (normalized.split("/").includes("..")) {
throw new Error(`tar entry contains parent traversal: ${entry}`);
}
}
}
// 创建压缩包
export function tarCreate(archivePath: string, sourceDir: string): void {
spawnSync("tar", ["-czf", archivePath, "-C", sourceDir, "."]);
}
// 解压到指定目录
export function tarExtract(archivePath: string, destDir: string): void {
listTarEntries(archivePath); // 先验证条目安全性
spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
}
// 读取 manifest(不解压整个文件)
export function tarExtractManifest(archivePath: string): string | null {
const result = spawnSync("tar", ["-xzf", archivePath, "-O", "manifest.json"]);
return result.status === 0 ? result.stdout : null;
}
还原流程
还原操作基于 manifest 驱动,确保只还原实际备份成功的文件:
// 路径映射:从备份项配置自动生成,确保一致性
export const RESTORE_MAP: Record<string, string> = Object.fromEntries(
BACKUP_ITEMS.map((item) => [item.dest, item.src])
);
export function restoreBackup(backupPath: string): RestoreResult {
// 1. 创建临时目录并解压
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
tarExtract(backupPath, tempDir);
// 2. 读取 manifest 获取实际备份的文件列表
const manifestPath = path.join(tempDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
const restored: string[] = [];
const skipped: string[] = [];
// 3. 基于 manifest.files 还原(只还原成功备份的文件)
for (const [backupPath, success] of Object.entries(manifest.files)) {
// 跳过备份失败的文件
if (!success) {
skipped.push(backupPath);
continue;
}
const projectPath = RESTORE_MAP[backupPath];
if (!projectPath) {
console.warn(`未知的备份路径: ${backupPath},跳过`);
skipped.push(backupPath);
continue;
}
const srcPath = path.join(tempDir, backupPath);
const destPath = path.join(PROJECT_ROOT, projectPath);
if (fs.existsSync(srcPath)) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.cpSync(srcPath, destPath, { recursive: true });
restored.push(projectPath);
} else {
skipped.push(backupPath);
}
}
// 4. 清理临时目录
fs.rmSync(tempDir, { recursive: true, force: true });
return {
restored,
skipped,
backupType: manifest.type,
version: manifest.version,
};
}
Dry-Run 模式详解
Dry-run(预演模式)是 CLI 工具中常见的安全特性,允许用户在实际执行前预览操作结果。本实现采用函数分离 + 条件渲染的模式。
下图展示了预览模式和实际执行模式的核心区别:
infographic compare-binary-horizontal-badge-card-arrow
data
title Dry-Run 模式与实际执行对比
desc 预览模式和实际还原的关键区别
items
- label 预览模式
desc 安全的只读预览
icon mdi/eye
children
- label 提取 manifest.json
desc 调用 tarExtractManifest 不解压整个归档
- label 读取 manifest.files
desc 获取实际备份的文件列表
- label 统计文件数量
desc 调用 tarList 计算每个路径的文件数
- label 不修改任何文件
desc 零副作用,可安全执行
- label 实际执行
desc 基于 manifest 的还原
icon mdi/content-save
children
- label 解压整个归档
desc 调用 tarExtract 提取所有文件
- label 读取 manifest.files
desc 获取实际备份成功的文件列表
- label 按 manifest 复制文件
desc 只还原 success: true 的文件
- label 显示跳过的文件
desc 报告 success: false 的文件
预览函数和执行函数
关键在于提供两个功能相似但副作用不同的函数:
// 预览函数:只读取 manifest,不解压不修改文件
export function getRestorePreview(backupPath: string): RestorePreviewItem[] {
// 只提取 manifest.json,不解压整个归档
const manifestContent = tarExtractManifest(backupPath);
if (!manifestContent) {
throw new Error("无法读取备份 manifest");
}
const manifest = JSON.parse(manifestContent);
const previewItems: RestorePreviewItem[] = [];
// 基于 manifest.files 生成预览
for (const [backupPath, success] of Object.entries(manifest.files)) {
if (!success) continue; // 跳过备份失败的文件
const projectPath = RESTORE_MAP[backupPath];
if (!projectPath) continue;
// 从归档中统计文件数量(不解压)
const files = tarList(backupPath);
const matchingFiles = files.filter(
(f) => f === backupPath || f.startsWith(`${backupPath}/`)
);
const fileCount = matchingFiles.length;
previewItems.push({
path: projectPath,
fileCount: fileCount || 1,
backupPath,
});
}
return previewItems;
}
// 执行函数:实际解压并复制文件
export function restoreBackup(backupPath: string): RestoreResult {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
tarExtract(backupPath, tempDir); // 实际解压
// 读取 manifest 驱动还原
const manifest = JSON.parse(
fs.readFileSync(path.join(tempDir, "manifest.json"), "utf-8")
);
const restored: string[] = [];
for (const [backupPath, success] of Object.entries(manifest.files)) {
if (!success) continue;
const projectPath = RESTORE_MAP[backupPath];
// ... 实际复制文件
fs.cpSync(srcPath, destPath, { recursive: true });
restored.push(projectPath);
}
return { restored, skipped: [], backupType: manifest.type };
}
两个函数的核心区别:
-
预览:调用
tarExtractManifest()只提取 manifest,再用tarList()统计文件数量 -
执行:调用
tarExtract()解压整个归档,基于 manifest.files 复制文件
组件层:条件分发
在 React 组件中,根据 dryRun 参数决定调用哪个函数:
interface RestoreAppProps {
dryRun?: boolean; // 是否为预览模式
force?: boolean; // 是否跳过确认
}
export function RestoreApp({ dryRun = false, force = false }: RestoreAppProps) {
const [result, setResult] = useState<{
items: RestorePreviewItem[] | string[];
backupType?: string;
skipped?: string[];
}>();
// 预览模式:只读取 manifest
const runDryRun = useCallback(() => {
const previewItems = getRestorePreview(selectedBackup);
setResult({ items: previewItems });
setStatus("done");
}, [selectedBackup]);
// 实际还原:基于 manifest 执行还原
const runRestore = useCallback(() => {
setStatus("restoring");
const { restored, skipped, backupType } = restoreBackup(selectedBackup);
setResult({ items: restored, backupType, skipped });
setStatus("done");
}, [selectedBackup]);
// 确认时根据模式分发
function handleConfirm() {
if (dryRun) {
runDryRun();
} else {
runRestore();
}
}
}
关键设计:
-
统一数据结构:
result可以容纳预览和执行两种结果 -
类型区分:预览返回
RestorePreviewItem[](含 fileCount),执行返回string[] -
额外信息:执行模式返回
backupType和skipped,用于显示完整信息
UI 层:差异化展示
预览模式和实际执行模式在 UI 上有明确区分:
{
/* 确认提示:显示备份类型和文件数量 */
}
<Text color="yellow">
{dryRun ? "[预览模式] " : ""}
确认还原 {result?.backupType} 备份? 此操作将覆盖现有文件
</Text>;
{
/* 完成状态:根据模式显示不同标题 */
}
<Text bold color="green">
{dryRun ? "预览模式" : "还原完成"}
</Text>;
{
/* 结果展示:预览模式显示文件数量统计 */
}
{
result?.items.map((item) => {
const isPreviewItem = typeof item !== "string";
const filePath = isPreviewItem ? item.path : item;
const fileCount = isPreviewItem ? item.fileCount : 0;
return (
<Text key={filePath}>
<Text color="green">{" "}+ </Text>
<Text>{filePath}</Text>
{/* 预览模式额外显示文件数量 */}
{isPreviewItem && fileCount > 1 && (
<Text dimColor> ({fileCount} 文件)</Text>
)}
</Text>
);
});
}
{
/* 统计文案:使用 "将" vs "已" 区分 */
}
<Text>
{dryRun ? "将" : "已"}还原: <Text color="green">{result?.items.length}</Text>{" "}
项
</Text>;
{
/* 显示跳过的文件(仅实际执行模式) */
}
{
!dryRun && result?.skipped && result.skipped.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="yellow">跳过的文件:</Text>
{result.skipped.map((file) => (
<Text key={file} dimColor>
{" "}- {file}
</Text>
))}
</Box>
);
}
{
/* 预览模式特有提示 */
}
{
dryRun && <Text color="yellow">这是预览模式,没有文件被修改</Text>;
}
{
/* 实际执行模式:显示后续步骤 */
}
{
!dryRun && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>后续步骤:</Text>
<Text dimColor>{" "}1. pnpm install # 安装依赖</Text>
<Text dimColor>{" "}2. pnpm build # 构建项目</Text>
</Box>
);
}
命令行使用
# 预览模式:查看将要还原的内容
pnpm koharu restore --dry-run
# 实际执行
pnpm koharu restore
# 跳过确认直接执行
pnpm koharu restore --force
# 还原最新备份(预览)
pnpm koharu restore --latest --dry-run
输出对比
预览模式输出(Full 备份):
备份文件: backup-2026-01-10-12-30-00-full.tar.gz
备份类型: full
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00
[预览模式] 确认还原 full 备份? 此操作将覆盖现有文件 (Y/n)
预览模式
+ src/content/blog (42 文件)
+ config/site.yaml
+ src/pages/about.md
+ .env
+ public/img (128 文件)
+ src/assets/lqips.json
+ src/assets/similarities.json
+ src/assets/summaries.json
将还原: 8 项
这是预览模式,没有文件被修改
预览模式输出(Basic 备份):
备份文件: backup-2026-01-10-12-30-00-basic.tar.gz
备份类型: basic
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00
[预览模式] 确认还原 basic 备份? 此操作将覆盖现有文件 (Y/n)
预览模式
+ src/content/blog (42 文件)
+ config/site.yaml
+ src/pages/about.md
+ .env
+ public/img/avatar.webp
将还原: 5 项
这是预览模式,没有文件被修改
实际执行输出(含跳过的文件):
还原完成
+ src/content/blog
+ config/site.yaml
+ src/pages/about.md
+ .env
+ public/img
跳过的文件:
- src/assets/lqips.json (备份时不存在)
已还原: 5 项
后续步骤:
1. pnpm install # 安装依赖
2. pnpm build # 构建项目
3. pnpm dev # 启动开发服务器
写在最后
能看到这里,那很厉害了,觉得还挺喜欢的话,欢迎给我一个 star 呢~
自认为这次实现的这个 CLI 对于我自己的需求来说,相当好用,只恨没有早一些实践,如果你看到这篇文章,可以放心大胆的去构建。
相关链接如下
React Ink
- Ink - GitHub - React for interactive command-line apps,官方仓库
- Ink UI - Ink 的 UI 组件库,提供 TextInput、Spinner、ProgressBar 等组件
- Using Ink UI with React to build interactive, custom CLIs - LogRocket - Ink UI 使用教程
- Building a Coding CLI with React Ink - 实战教程,包含流式输出实现
- React + Ink CLI Tutorial - FreeCodeCamp - 入门教程
- Node.js CLI Apps Best Practices - GitHub - Node.js CLI 最佳实践清单
Git 同步 Fork
- Syncing a fork - GitHub Docs - 官方文档
- Git Upstreams and Forks - Atlassian - Atlassian 的详细教程
- How to Sync Your Fork with the Original Git Repository - FreeCodeCamp - 完整同步指南
状态机与 useReducer
- How to Use useReducer as a Finite State Machine - Kyle Shevlin - 将 useReducer 用作状态机的经典文章
- Turning your React Component into a Finite State Machine - DEV - 状态机实战教程
我用 Gemini 3 Pro 手搓了一个并发邮件群发神器(附源码)
从零实现富文本编辑器#10-React视图层适配器的模式扩展
在编辑器最开始的架构设计上,我们就以MVC模式为基础,分别实现模型层、核心层、视图层的分层结构。在先前我们讨论的主要是模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,在这里我们以React为例,讨论其作为视图层的模式扩展设计。
- 开源地址: github.com/WindRunnerM…
- 在线编辑: windrunnermax.github.io/BlockKit/
- 项目笔记: github.com/WindRunnerM…
从零实现富文本编辑器系列文章
概述
多数编辑器实现了本身的视图层,而重新设计视图层需要面临渲染问题,诸如处理复杂的DOM更新、差量更新的成本,在业务上也无法复用组件生态,且存在新的学习成本。因此我们需要能够复用现有的视图层框架,例如React/Vue/Angular等,这就需要在最底层的架构上就实现可扩展视图层的核心模式。
复用现有的视图层框架也就意味着,在核心层设计上不感知任何类型的视图实现,针对诸如选区的实现也仅需要关注最基础的DOM操作。而这也就导致我们需要针对每种类型的框架都实现对应的视图层适配器,即使其本身并不会特别复杂,但也会是需要一定工作量的。
虽然独立设计视图层可以解决视图层适配成本问题,但相应的会增加维护成本以及包本身体积,因此在我们的编辑器设计上,我们还是选择复用现有的视图层框架。然而,即使复用视图层框架,适配富文本编辑器也并非是一件简单的事情,需要关注的点包括但不限于以下几部分:
- 视图层初始状态渲染: 生命周期同步、状态管理、渲染模式、
DOM映射状态等。 - 内容编辑的增量更新: 不可变对象、增量渲染、
Key值维护等。 - 渲染事件与节点检查: 脏
DOM检查、选区更新、渲染Hook等。 - 编辑节点的组件预设: 零宽字符、
Embed节点、Void节点等。 - 非编辑节点内容渲染: 占位节点、只读模式、插件模式、外部节点挂载等。
此外,基于React实现视图层适配器,相当于重新深入学习React的渲染机制。例如使用memo来避免不必要的重渲染、使用useLayoutEffect来控制DOM渲染更新时机、严格控制父子组件事件流以及副作用执行顺序等、处理脏DOM的受控更新等。
除了这些与React相关的实现,还有一些样式有关的问题需要注意。例如在HTML默认连续的空白字符,包括空格、制表符和换行符等,在渲染时会被合并为一个空格,这样就会导致输入的多个空格在渲染时只显示一个空格。
为了解决这个问题,有些时候我们可以直接使用HTML实体 来表示这些字符来避免渲染合并,然而我们其实可以用更简单的方式来处理,即使用CSS来控制空白符的渲染。下面的几个样式分别控制了不同的渲染行为:
-
whiteSpace: "pre-wrap": 表示在渲染时保留换行符以及连续的空白字符,但是会对长单词进行换行。 -
wordBreak: "break-word": 可以防止长单词或URL溢出容器,会在单词内部分行,对中英文混合内容特别有用。 -
overflowWrap: "break-word": 同样是处理溢出文本的换行,但自适应宽度时不考虑长单词折断,属于当前标准写法。
<div
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "break-word",
}}
></div>
这其中word-break是早期WebKit浏览器的实现,给word-break添加了一个非标准的属性值break-word。这里存在问题是,CSS在布局计算中,有一个环节叫Intrinsic Size即固有尺寸计算,此时如果给容器设置width: min-content时,容器就坍塌成了一个字母的宽度。
<style>
.wrapper { background: #eee; padding: 10px; width: min-content; border: 1px solid #999; }
.standard { overflow-wrap: break-word; }
.legacy { word-break: break-word; }
</style>
<h3>overflow-wrap: break-word</h3>
<div class="wrapper standard">
Supercalifragilistic
</div>
<h3>word-break: break-word</h3>
<div class="wrapper legacy">
Supercalifragilistic
</div>
生命周期同步
从最开始的编辑器设计中,我们就已经将核心层和视图层分离,并且为了更灵活地调度编辑器,我们将编辑器实例化的时机交予用户来控制。这种情况下若是暴露出Editor的ref/useImperativeHandle接口实现,就可以不必要设置null的状态,通常情况下的调度方式类似于下面的实现:
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return <Editable editor={editor} />;
}
此外,我们需要在SSR渲染或者生成SSG静态页面时,编辑器的渲染能够正常工作。那么通常来说,我们需要独立控制编辑器对于DOM操作的时机,实例化编辑器时不会进行任何DOM操作,而只有在DOM状态准备好后,才会进行DOM操作。那么最常见的时机,就是组件渲染的副作用Hook。
const Editable: React.FC = (props) => {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
}, [editor]);
return <div ref={ref}></div>;
}
然而在这种情况下,编辑器实例化的生命周期和Editable组件的生命周期必须要同步,这样才能保证编辑器的状态和视图层的状态一致。例如下面的例子中,Editable组件的生命周期与Editor实例的生命周期不一致,会导致视图所有的插件卸载。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
{key % 2 ? <div><Editable editor={editor} /></div> : <Editable editor={editor} />}
</Fragment>
);
}
上面的例子中,若是Editable组件层级一致其实是没问题的,即使存在条件分支React也会直接复用,但层级不一致的话会导致Editable组件级别的卸载和重新挂载。当然本质上就是组件生命周期导致的问题,因此可以推断出若是同一层级的组件,设置为不同的key值也会触发同样的效果。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
<Editable key={key} editor={editor} />
</Fragment>
);
}
这里的核心原因实际上是,Editable组件的useEffect钩子会在组件卸载时触发清理函数,而编辑器实例化的时候是在最外层执行的。那么编辑器实例化和卸载的时机是无法对齐的,卸载后所有的编辑器功能都会销毁,只剩下纯文本的内容DOM结构。
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
return () => {
editor.destroy();
};
}, [editor]);
此外,实例化独立的编辑器后,同样也不能使用多个Editable组件来实现编辑器的功能,也就是说每个编辑器实例都必须要对应一个Editable组件,多个编辑器组件的调度会导致整个编辑器状态混乱。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return (
<Fragment>
<Editable editor={editor} />
<Editable editor={editor} />
</Fragment>
);
}
因此为了避免类似的问题,我们是应该以最开始的实现方式一致,也就是说整个编辑器组件都应该是合并为独立组件的。那么类似于上述出现问题的组件应该有如下的形式,将实例化的编辑器和Editable组件合并为独立组件,这样就能够将生命周期完全对齐而非交给用户侧实现。
const Editor = () => {
const editor = useMemo(() => new Editor(), []);
return <Editable editor={editor} />;
};
const App = () => {
const [key, setKey] = useState(0);
return (
<Fragment>
<button onClick={() => setKey((k) => k + 1)}>切换</button>
{key % 2 ? <div><Editor /></div> : <Editor />}
</Fragment>
);
}
实际上我们完全可以将实例化编辑器这个行为封装到Editable组件中的,但是为了更灵活地调度编辑器,将实例化放置于外层更合适。此外,从上述的Effect中实际上可以看出,与onMount实例挂载对齐的生命周期应该是Unmount,因此这里更应该调度编辑器卸载DOM的事件。
然而若是将相关的事件粒度拆得更细,即在插件中同样需要定义为onMount和onUnmount的生命周期,这样就能更好地控制相关处理时机问题。然而这种情况下,对于用户来说就需要有更复杂的插件逻辑,与DOM相关的事件绑定、卸载等都需要用户明确在哪个生命周期调用。
即使分离了更细粒度的生命周期,编辑器的卸载时机仍然是需要主动调用的,不主动调用则会存在可能的内存泄漏问题。在插件的实现中用户是可能直接向DOM绑定事件的,这些事件在编辑器卸载时是需要主动解绑的,将相关的约定都学习一边还是存在一些心智负担。
实际上如果能实现更细粒度的生命周期,对于整个编辑器的实现是更高效的,毕竟若是避免实例化编辑器则可以减少不必要的状态变更和事件绑定。因此这里还是折中实现,若是用户需要避免编辑器的卸载事件,可以通过preventDestroy参数来实现,用户在编辑器实例化生命周期结束主动卸载。
export const Editable: React.FC<{
/**
* 避免编辑器主动销毁
* - 谨慎使用, 生命周期结束必须销毁编辑器
* - 注意保持值不可变, 否则会导致编辑器多次挂载
*/
preventDestroy?: boolean;
}> = props => {
const { preventDestroy } = props;
const { editor } = useEditorStatic();
useLayoutEffect(() => {
const el = ref.current;
el && editor.mount(el);
return () => {
editor.unmount();
!preventDestroy && editor.destroy();
};
}, [editor, preventDestroy]);
}
状态管理
对于状态管理我们需要从头开始,在这里我们先来实现全量更新模式,但是要在架构设计上留好增量的更新模式。那么思考核心层与视图层中的通信与状态管理,使用Context/Redux/Mobx是否可以避免自行维护各个状态对象,也可以达到局部刷新而不是刷新整个页面的效果。
深入思考一下似乎并不可行,以Context管理状态为例,即使有immer.js似乎也做不到局部刷新,因为整个delta的数据结构不能够达到非常完整的与props对应的效果。诚然我们可以根据op & attributes作为props再在组件内部做数据转换,但是这样似乎并不能避免维护一个状态对象。
由此最基本的是应该要维护一个LineState对象,每个op可能与前一个或者后一个有状态关联,以及行属性需要处理,这样一个基础的LineState对象是必不可少的。再加上是要做插件化的,那么给予react组件的props应该都实际上可以隐藏在插件里边处理。
如果我们使用immer的话,似乎只需要保证插件给予的参数是不变的即可,但是同样的每一个LineState都会重新调用一遍插件化的render方法。这样确实造成了一些浪费,即使能够保证数据不可变即不会再发生re-render,但是如果在插件中解构了这个对象或者做了一些处理,那么又会触发更新。
那么既然LineState对象不可避免,如果再在这上边抽象出一层BlockState来管理LineState。再通过ContentChange事件作为bridge,以及这种一层管理一层的方式,精确地更新每一行,减少性能损耗,甚至于精确的得知究竟是哪几个op更新了,做到完全精准更新也不是不可能。
由此回到最初实现State模块更新文档内容时,我们是直接重建了所有的LineState以及LeafState对象,然后在React视图层的BlockModel中监听了OnContentChange事件,以此来将BlockState的更新应用到视图层。
delta.eachLine((line, attributes, index) => {
const lineState = new LineState(line, attributes, this);
lineState.index = index;
lineState.start = offset;
lineState.key = Key.getId(lineState);
offset = offset + lineState.length;
this.lines[index] = lineState;
});
这种方式简单直接,全量更新状态能够保证在React的状态更新,然而这种方式的问题在于性能,当文档内容非常大的时候,全量计算将会导致大量的状态重建。并且其本身的改变也会导致React的diff差异进而全量更新文档视图,这样的性能开销通常是不可接受的。
不过,上述的监听OnContentChange事件来更新视图的方式,是完全没有问题的,这也是连接编辑器核心层和视图层的重要部分。React的视图更新需要setState来触发状态变更,那么从核心层的OnContentChange事件中触发更新,就可以将状态的更新应用到视图层。
const BlockView: FC = props => {
const [lines, setLines] = useState(() => state.getLines());
const onContentChange = useMemoFn(() => {
setLines(state.getLines());
});
}
这里其实还有个有趣的事情,假设我们在核心层中同步地多次触发了状态更新,则会导致多次视图层更新。这是个比较常见的事情,当一个事件作用到状态模型时,可能调用了若干指令,产生了多个Op,若每个Op的应用都进行一次视图同步,代价会十分高昂。
不过在React中,这件事的表现并没有那么糟糕,因为React本身会合并起来异步更新视图,但我们仍然可以避免多次无效的更新,可以减少React设置状态这部分的开销。具体的逻辑是,同步更新状态时,通过一个状态变量守卫住React状态更新,直到异步操作时才更新视图层。
在下面的这段代码中,可以举个例子同步等待刷新的队列为||||||||,每一个|都代表着一次状态更新。进入更新步骤即首个|后, 异步队列行为等待, 同步的队列由于!flushing全部被守卫。主线程执行完毕后, 异步队列开始执行, 此时拿到的是最新数据, 以此批量重新渲染。
/**
* 数据同步变更, 异步批量绘制变更
*/
const onContentChange = useMemoFn(() => {
if (flushing.current) return void 0;
flushing.current = true;
Promise.resolve().then(() => {
flushing.current = false;
setLines(state.getLines());
});
});
回到更新这件事本身,即使全部重建了LineState以及LeafState,也需要尽可能找到其原始的LineState以便于复用其key值,避免整个行的re-mount。当然即使复用了key值,因为重建了State实例,React也会继续后边的re-render流程。
这样的全量更新自然是存在性能浪费的,特别是我们的数据模型都是基于原子化Op的,不实现增量更新本身也并不合理。因此我们需要在核心层实现增量更新的逻辑,例如Immutable的状态序列对象、Key值的复用等,视图层也需要借助memo等来避免无效渲染。
渲染模式
我们希望实现的是视图层分离的通信架构,相当于所有的渲染都采用React,类似于Slate的架构设计。而Facebook在推出的Draft富文本引擎中,是使用纯React来渲染的,然后当前Draft已经不再维护,转而推出了Lexical。
后来我发现Lexical虽然是Facebook推出的,但是却没用React进行渲染,从DOM节点上就可以看出来是没有Fiber的,因此可以确定普通的节点并不是React渲染的。诚然使用React可能存在性能问题,而且由于非受控模式下可能会造成crash,但是能够直接复用视图层还是有价值的。
在Lexical的README中可以看到是可以支持React的,那么这里的支持实际上仅有DecoratorNode可以用React来渲染,例如在Playground中加入ExcaliDraw画板的组件的话,就可以发现svg外的DOM节点是React渲染的,可以发现React组件是额外挂载上去的。
在下面的例子中,可以看到p标签的属性中是lexical注入的相关属性,而子级的span标签则是lexical装饰器节点,用于渲染React组件,这部分都并非是React渲染的。而明显的,button标签则明显是React渲染的,因为其有Fiber相关属性,这也验证了分离渲染模式。
<!--
__lexicalKey_gqfof: "4"
__lexicalLineBreak: br
__lexicalTextContent: "\n\n\n\n"
-->
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<!--
__lexicalKey_gqfof: "5"
_reactListening2aunuiokal: true
-->
<span class="editor-image" data-lexical-decorator="true" contenteditable="false">
<!--
__reactFiber$zski0k5fvkf: { ... }
__reactProps$zski0k5fvkf: { ... }
-->
<button class="excalidraw-button "><!-- ... --></button>
</span>
</p>
也就是说,仅有Void/Embed类型的节点才会被React渲染,其他的内容都是普通的DOM结构。这怎么说呢,就有种文艺复兴的感觉,如果使用Quill的时候需要将React结合的话,通常就需要使用ReactDOM.render的方式来挂载React节点。
在Lexical还有一点是,需要协调的函数都需要用$符号开头,这也有点PHP的文艺复兴。Facebook实现的框架就比较喜欢规定一些约定性的内容,例如React的Hooks函数都需要以use开头,这本身也算是一种心智负担。
那么有趣的事,在Lexical中我是没有看到使用ReactDOM.render的方法,所以我就难以理解这里是如何将React节点渲染到DOM上的。于是在useDecorators中找到了Lexical实际上是以createPortal的方法来渲染的。
// https://react-lab.skyone.host/
const Context = React.createContext(1);
const Customer = () => <span>{React.useContext(Context)}</span>;
const App = () => {
const ref1 = React.useRef<HTMLDivElement>(null);
const ref2 = React.useRef<HTMLDivElement>(null);
const [decorated, setDecorated] = React.useState<React.ReactPortal | null>(null);
React.useEffect(() => {
const div1 = ref1.current!;
setDecorated(ReactDOM.createPortal(<Customer />, div1));
const div2 = ref2.current!;
ReactDOM.render(<Customer />, div2);
}, []);
return (
<Context.Provider value={2}>
{decorated}
<div ref={ref1}></div>
<div ref={ref2}></div>
<Customer></Customer>
</Context.Provider>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
使用这种方式实际与ReactDOM.render效果基本一致,但是createPortal是可以自由使用Context的,且在React树渲染的位置是用户挂载的位置。实际上讨论这部分的主要原因是,我们在视图层的渲染并非需要严格使用框架来渲染,分离渲染模式也是能够兼容性能和生态的处理方式。
DOM 映射状态
在实现MVC架构时,理论上控制器层以及视图层都是独立的,控制器不会感知视图层的状态。但是在我们具体实现过程中,视图层的DOM是需要被控制器层处理的,例如事件绑定、选区控制、剪贴板操作等等,那么如何让控制器层能够操作相关的DOM就是个需要处理的问题。
理论上而言,我们在DOM上的设计是比较严格的,即data-block节点属块级节点,而data-node节点属行级节点,data-leaf节点则属行内节点。block节点下只能包含node节点,而node节点下只能包含leaf节点。
<div contenteditable style="outline: none" data-block>
<div data-node><span data-leaf><span>123</span></span></div>
<div data-node>
<span data-leaf><span contenteditable="false">321</span></span>
</div>
<div data-node><span data-leaf><span>123</span></span></div>
</div>
那么在这种情况下,我们是可以在控制器层通过遍历DOM节点来获取相关的状态的,或者诸如querySelectorAll、createTreeWalker等方法来获取相关的节点。但是这样明显是会存在诸多无效的遍历操作,因此我们需要考虑是否有更高效的方式来获取相关的节点。
在React中我们可以通过ref来获取相关的节点,那么如何将DOM节点对象映射到相关编辑器对象上。我们此时存在多个状态对象,因此可以将相关的对象完整一一映射到对应的主级DOM结构上,而且Js中我们可以使用WeakMap来维护弱引用关系。
export class Model {
/** DOM TO STATE */
protected DOM_MODEL: WeakMap<HTMLElement, BlockState | LineState | LeafState>;
/** STATE TO DOM */
protected MODEL_DOM: WeakMap<BlockState | LineState | LeafState, HTMLElement>;
/**
* 映射 DOM - LeafState
* @param node
* @param state
*/
public setLeafModel(node: HTMLSpanElement, state: LeafState) {
this.DOM_MODEL.set(node, state);
this.MODEL_DOM.set(state, node);
}
}
在React组件的ref回调函数中,我们需要通过setLeafModel方法来将DOM节点映射到LeafState上。在React中相关执行时机为ref -> layout effect -> effect,且需要保证引用不变, 否则会导致回调在re-render时被多次调用null/span状态。
export const Leaf: FC<LeafProps> = props => {
/**
* 处理 ref 回调
*/
const onRef = useMemoFn((dom: HTMLSpanElement | null) => {
dom && editor.model.setLeafModel(dom, lineState);
});
return (
<span ref={onRef} {...{ [LEAF_KEY]: true }}>
{props.children}
</span>
);
};
总结
在先前我们主要讨论了模型层以及核心层的设计,即数据模型以及编辑器的核心交互逻辑,也包括了部分DOM相关处理的基础实现。还重点讲述了选区的状态同步、输入的状态同步,并且处理了相关实现在浏览器中的兼容问题。
在当前部分,我们主要讨论了视图层的适配器设计,主要是全量的视图初始化渲染,包括生命周期同步、状态管理、渲染模式、DOM映射状态等。接下来我们需要处理变更的增量更新,这属于性能方面的优化,我们需要考虑如何最小化DOM以及Op操作,以及在React中实现增量渲染的方式。
每日一题
参考
GitHub Fork 协作完整流程
GitHub Fork 协作完整流程
适用于:开源项目二次开发、插件扩展、给上游项目提交 PR(Pull Request)
一、Fork 是什么?
Fork 是把别人的 GitHub 仓库复制一份到你自己的账号下:
原仓库(upstream) → 你的仓库(origin)
你在自己的仓库里可以随意修改代码,而不会影响原项目。
二、整体协作流程
flowchart TD
A[原项目 GitHub 仓库] -->|Fork| B[你自己的 GitHub 仓库]
B -->|git clone| C[你的本地仓库]
A -->|git fetch upstream| C
C -->|git merge upstream/main| C
C -->|git push origin| B
B -->|Pull Request| A
三、Fork 项目(网页操作)
-
打开你要 Fork 的仓库,例如:
https://github.com/vercel/next.js -
点击右上角 Fork
-
选择你的 GitHub 账号
-
得到你的仓库:
https://github.com/你的用户名/next.js
四、Clone 到本地
git clone https://github.com/你的用户名/next.js.git
cd next.js
五、配置 upstream(非常重要)
查看当前远程仓库:
git remote -v
你会看到:
origin https://github.com/你的用户名/next.js.git
添加原仓库:
git remote add upstream https://github.com/vercel/next.js.git
再确认:
git remote -v
应该是:
origin 你自己的仓库
upstream 原项目仓库
六、同步原项目最新代码(标准流程)
git checkout main
git fetch upstream
git merge upstream/main
git push origin main
含义:
用原作者的 main 更新你的 main,并推送到你自己的 GitHub
七、开发你的功能
git checkout -b my-feature
# 修改代码
git add .
git commit -m "Add my feature"
git push origin my-feature
八、提交 Pull Request
-
打开你的 GitHub 仓库
-
GitHub 会提示:
Compare & Pull Request -
点击并填写说明
-
提交 → 原项目维护者审核
九、为什么必须使用 upstream?
| 远程名 | 作用 |
|---|---|
| origin | 你自己的 GitHub 仓库 |
| upstream | 原作者的 GitHub 仓库 |
你只向 origin push,但要从 upstream 获取最新版本。
十、fetch vs merge 的正确用法
sequenceDiagram
participant U as GitHub upstream
participant L as Local Repo
participant O as GitHub origin
U->>L: git fetch upstream
L->>L: upstream/main 更新
L->>L: git merge upstream/main
L->>O: git push origin main
-
fetch= 下载真实世界 -
merge= 使用真实世界
永远不要跳过 fetch。
十一、错误示例(不要这样)
git merge upstream/main # ❌ 如果你没先 fetch
你合并的可能是 几个月前缓存的 upstream,非常危险。
十二、最佳实践总结
Fork 项目的生存法则
只向 origin 写代码
只从 upstream 读更新
fetch 在前,merge 在后
Git 焚决!一个绝招助你找回丢失的代码文件!
如何坚持更新技术博客减少摩擦力复盘
如何洞察下一个博客主题?
前言
做内容创作最头疼的问题之一就是:下一个主题写什么?今天给大家分享几个我一直在用的方法,帮你找到源源不断的创作灵感。
方法一:从日常工作中找痛点(最直接)
核心思路
记录每天遇到的"卡点",把解决过程写成"我是怎么解决的"。
实际案例
- 今天用AI还原设计稿时遇到问题 → 就有了"AI还原设计稿的六步法"
- 画架构图时觉得工具麻烦 → 就有了"树状架构图提示词"
实操建议
- 每天记录3个"今天遇到的麻烦"
- 一周后回看,选最常出现或最痛的那个
为什么有效
因为你的痛点,往往也是别人的痛点。你解决了,别人也需要。
方法二:从学习过程中找"顿悟时刻"
核心思路
记录"原来是这样"的瞬间,把"从不懂到懂"的过程写出来。
实际案例
- 学Vue时突然理解computed和methods的区别 → "我终于搞懂了computed和methods的区别"
- 看设计模式时突然明白某个模式 → "这个设计模式原来这么简单"
实操建议
- 学习时用"费曼学习法":学完立刻用自己的话讲一遍
- 讲不清楚的地方,就是好主题
为什么有效
你的学习过程,就是最好的教学内容。别人也在学,也需要这个"顿悟时刻"。
方法三:从读者反馈中找方向
核心思路
看评论区、私信、社群提问,把常见问题整理成主题。
实际案例
- 很多人问"怎么提高开发效率" → 写"我的效率提升方法"
- 很多人问"AI怎么用" → 写"AI辅助开发的实战经验"
实操建议
- 建一个"问题收集本",记录所有被问到的问题
- 出现3次以上的问题,优先写
为什么有效
读者的问题,就是最好的主题。他们需要什么,你就写什么。
方法四:从行业趋势中找热点
核心思路
关注技术社区、GitHub趋势、行业报告,结合自己的经验,给出实用解读。
实际案例
- 新框架发布 → "我第一时间体验了XX框架,这些坑你要注意"
- 新工具出现 → "这个工具我用了3个月,告诉你值不值得学"
实操建议
- 每周花30分钟看技术社区热门话题
- 选1-2个自己熟悉的,结合经验写
为什么有效
热点自带流量,但你的经验解读才是价值所在。
方法五:从个人经验中提炼"方法论"
核心思路
把重复做的事总结成方法,把"我是怎么做的"写成可复制的步骤。
实际案例
- 你总是能快速定位bug → "我的调试方法论"
- 你总是能快速上手新技术 → "我是怎么快速学习新技术的"
实操建议
- 问自己:"我有什么做得比别人好的地方?"
- 把这个"好"拆解成可复制的步骤
为什么有效
你的优势,就是别人的需求。方法论最有价值。
方法六:用"转述法"延伸主题
核心思路
读好书/好文章,转述核心观点,结合自己的经历,给出应用建议。
实际案例
- 读《掌控习惯》→ "我终于知道怎么改变习惯了"
- 看技术文章 → "这篇文章让我明白了XX,我这样应用"
实操建议
- 每周读1-2篇好文章或1章好书
- 用"勾子+转述+个人连接+方法提炼"的结构写
为什么有效
好书好文章的观点,加上你的实践,就是新内容。
方法七:从"失败经验"中找价值
核心思路
把踩过的坑写成"避坑指南",把"我为什么失败"写成"你可以这样避免"。
实际案例
- 项目上线出问题 → "这次上线踩的坑,希望你别再踩"
- 技术选型错误 → "我为什么选错了技术栈,以及我的反思"
实操建议
- 失败时立刻记录:问题、原因、解决方案
- 一周后回看,写成"避坑指南"
为什么有效
你的失败,就是别人的经验。避坑内容最有价值。
实用工具和方法
主题收集表(建议每天填)
日期:____
1. 今天遇到的麻烦:___________
2. 今天的顿悟时刻:___________
3. 被问到的问题:___________
4. 看到的热点话题:___________
5. 我的优势/经验:___________
主题优先级判断
评分标准(每个1分,满分5分):
- ✅ 我自己很痛(1分)
- ✅ 很多人问过(1分)
- ✅ 我有独特经验(1分)
- ✅ 容易写成方法(1分)
- ✅ 有实用价值(1分)
3分以上就可以写,4-5分优先写
快速启动建议
如果你现在就想找下一个主题:
- 回顾最近一周的工作,找出最让你头疼的3件事
- 看看你的笔记/代码注释,找出你写过的"这里要注意"
- 问问自己:"如果只能教别人一件事,我会教什么?"
这三个问题的答案,就是你的下一个主题。
我的建议
不要等"完美主题",先写起来
- ✅ 小主题也可以写,比如"一个小技巧"
- ✅ 不完美也可以分享,比如"我的学习心得"
- ✅ 重要的是持续输出,主题会在过程中自然出现
记住
最好的主题,往往来自你最真实的经历。
你现在最想分享的是什么?或者最近遇到的最大麻烦是什么?这可能是你的下一个主题。
总结
洞察博客主题的7个方法:
- 从日常工作中找痛点 - 最直接,最真实
- 从学习过程中找顿悟 - 最有共鸣,最有价值
- 从读者反馈中找方向 - 最精准,最有效
- 从行业趋势中找热点 - 最及时,最有流量
- 从个人经验中提炼方法 - 最独特,最专业
- 用转述法延伸主题 - 最简单,最容易
- 从失败经验中找价值 - 最实用,最受欢迎
核心原则:
- 不要等完美主题,先写起来
- 你的真实经历,就是最好的主题
- 持续记录,定期回顾,主题自然会出现
2025年,第二款创业项目,pxcharts超级表格,最新进展来啦
"当我们的表格越来越宽,列数从10到50再到100,我终于意识到:数据不是扁平的,是我们把它压扁了。"
作为一个在数据可视化领域摸爬滚打多年的开发者+创业者,我今天想和大家聊聊我们最新的AI产品 pxcharts 多维表格,以及我最近上线的新功能——子记录。(子记录功能已上线,大家感兴趣可以体验一下)
![]()
pxcharts是什么?
简单说,pxcharts 是我们团队精心打造的AI多维表格产品。但这样讲太官方了,让我换个说法:
它是我对"如果Excel生在2024年,会长什么样"这个问题的回答。
![]()
传统的电子表格像一张无限大的纸,我们在这张纸上"摊大饼"——不断增加列、不断增加行,直到这张"饼"大到我们自己也找不到想要的数据。pxcharts想做的,是让数据自然生长而不是被动堆砌。
最近我们花了大量精力在设计和实现子记录功能(当然也是用户反馈最多的问题),好在今天终于实现了,下面我就和大家分享一下这个功能。
同时为了让大家更好的理解和研究多维表格, 我开源了一个基础版本的多维表格,大家也可以学习参考一下:
github 地址:github.com/MrXujiang/p…
体验地址:pxcharts.turntip.cn
ps:后续我会定期同步我们创业的进展,大家有好的想法,建议,也欢迎随时交流反馈~
子记录:让表格有了"层级思维"
![]()
它是怎么工作的?
想象这样一个场景:我们在管理一个产品需求表格,每一行是一个需求。但很快我们会发现,每个需求下面还有子任务,子任务下面还有Bug,Bug下面还有测试用例...
传统的做法是:
- 新建一个"子任务"列,用逗号分隔所有子任务
- 或者干脆新建一张"子任务表",然后用VLOOKUP来回查找
- 最糟糕的是,直接把子任务作为新行添加,然后手动维护层级关系
pxcharts 的子记录功能做了件底层复杂(数据结构设计)但非常有价值的事:让表格行也能"生孩子" 。
右键点击任意行序号 → "创建子记录" → 一个新的子行就出现了:
![]()
为什么这样设计?
这个设计背后有三个核心思考:
1. 数据本来就有层级,我们只是还原它
看看我们周围的世界:
- 公司 → 部门 → 团队 → 个人
- 产品 → 模块 → 功能 → 用户故事
- 年 → 季度 → 月 → 周 → 日
层级是信息的自然组织方式。扁平化只是为了适应工具限制而做的妥协。
虽然在设计多维表格初期,我的设计哲学一直是把复杂的事情做简单,但是通过市场的辩证之后,我还是“妥协”了。
2. 局部细节不应该污染整体视图
传统表格的问题在于,为了容纳所有细节,我们不得不不断增加列。最后得到一个50列的"巨表",但90%的时间我们只需要看前5列。
![]()
子记录让"细节待在细节该待的地方"——只有当你需要看某个需求的子任务时,才展开它;其他时候,它安安静静地待在父行里。
3. AI时代,结构化比堆砌更重要
AI擅长处理结构化数据,但不擅长从一堆扁平的数据中还原层级关系。
通过子记录,我们让数据结构自带"上下文",这让AI能更好地理解数据间的关系,提供更智能的建议。
那么,什么时候该用子记录呢?
经过这几天的内测(已有100人获取到了内测码并频繁使用),我发现子记录在几个场景下特别有用:
1. 产品需求管理
![]()
2. 项目任务拆解
![]()
3. 知识库组织
![]()
4. 客户跟进管理
![]()
当然还有很多场景,大家可以慢慢探索,后续我也会持续迭代,上线更多高价值的功能。
技术实现的小心思
作为技术博主,我知道你们肯定好奇这个功能是怎么实现的。简单说几个关键点:
数据结构:我们采用了邻接表模型+物化路径的混合方案。这样既支持高效的层级查询,又便于权限控制和数据导出。
前端渲染:虚拟滚动是必须的,但层级缩进让问题复杂化了。我们的解决方案是"分层虚拟滚动"——每一层都有自己的虚拟滚动容器。
![]()
AI集成:子记录让AI有了"上下文感"。比如当你在一个需求下创建子记录时,AI会基于父需求的内容智能推荐子任务的标题和属性。
开源,是为了更好的共创
pxcharts 我提供了一个开源版本,GitHub地址我放在文末了。为什么开源?
因为我觉得数据组织方式的革新,不应该被某个产品独占。
我们每天都在和表格打交道,但表格的形态几十年没变过了。也许通过开源,我们能一起探索出更多有趣的可能性。
写在最后
回到开头的那个比喻:数据不是扁平的,是我们把它压扁了。
子记录功能只是一个开始。我们还在实验更多让数据"自然生长"的功能:智能分组、动态视图、关系图谱...
如果大家也对让数据回归本真形态感兴趣,欢迎参考 pxcharts。
毕竟,在AI时代,结构化的思考比堆砌的数据更有价值。
而一个好的工具,应该让思考更自然,而不是更复杂。
GitHub地址:github.com/MrXujiang/p…