[{"data":1,"prerenderedAt":1826},["ShallowReactive",2],{"blog-taxonomies":3,"blog-stats":507,"post-2025\u002F2025-11-23-undo-redo-机制具体实现":1083},[4,11,16,21,25,29,36,39,44,47,52,58,63,66,71,76,80,84,87,93,96,101,105,110,113,117,122,127,132,136,141,143,146,151,155,157,162,165,169,173,176,179,183,186,190,193,197,200,205,208,211,214,219,222,228,234,237,239,245,249,253,256,260,266,271,276,281,283,287,290,293,298,301,304,306,308,311,314,316,318,321,327,332,337,342,347,352,354,356,358,361,365,367,369,371,374,376,378,381,386,390,393,396,400,404,407,411,413,415,417,421,423,425,428,431,433,435,437,439,441,443,446,448,450,454,456,459,461,463,465,467,469,471,473,477,483,486,488,491,493,495,497,499,501,503,505],{"category":5,"tags":6},"笔记",[7,8,9,10],"QQ","插件","LiteLoader","美化",{"category":12,"tags":13},"其他",[14,15,10],"博客","IP签名",{"category":17,"tags":18},"杂谈",[19,20],"互联网","观点",{"category":5,"tags":22},[14,23,24],"GitHub","数据可视化",{"category":5,"tags":26},[14,27,28],"访问统计","不蒜子",{"category":30,"tags":31},"工具",[32,33,34,35],"Python","爬虫","选课脚本","广州大学",{"category":5,"tags":37},[38,5],"数学分析",{"category":40,"tags":41},"算法",[42,40,43],"数据结构","线性表",{"category":40,"tags":45},[42,46],"复习",{"category":5,"tags":48},[49,50,51],"高等代数","线性代数","知识点",{"category":5,"tags":53},[14,54,55,56,57],"Typora","PicGo","图床","Markdown",{"category":30,"tags":59},[60,61,62],"Jupyter","工具配置","中文",{"category":30,"tags":64},[65,61,10],"VSCode",{"category":30,"tags":67},[68,69,70,35],"路由器","OpenWrt","校园网",{"category":12,"tags":72},[73,74,75],"网络协议","Wireshark","DEVP2P",{"category":5,"tags":77},[73,74,78,79],"Lua","解码器",{"category":5,"tags":81},[82,5,83],"常微分方程","数学",{"category":5,"tags":85},[86,5,83],"概率论",{"category":30,"tags":88},[89,90,91,92],"Docker","WSL","Windows","磁盘清理",{"category":30,"tags":94},[32,33,95,35],"课程表",{"category":5,"tags":97},[14,98,99,100],"Hexo","Gitalk","评论系统",{"category":5,"tags":102},[14,103,98,104],"Obsidian","写作工具",{"category":12,"tags":106},[107,108,109],"课题研究","泊松分布","参数估计",{"category":12,"tags":111},[107,108,109,112],"组会",{"category":12,"tags":114},[109,115,108,116],"IM模型","统计学",{"category":17,"tags":118},[119,120,121],"软考","网络规划","信息系统项目管理",{"category":30,"tags":123},[124,125,126],"Arch Linux","AppImage","Linux",{"category":5,"tags":128},[129,130,131],"编程语言","计算机科学","内存管理",{"category":5,"tags":133},[134,135],"Canvas","前端",{"category":5,"tags":137},[14,138,139,140],"GitHub Actions","Vite","部署",{"category":5,"tags":142},[135],{"category":5,"tags":144},[135,145],"系统设计",{"category":5,"tags":147},[135,148,149,150],"安全","XSS","CSRF",{"category":152,"tags":153},"项目",[134,154],"PixiJS",{"category":152,"tags":156},null,{"category":40,"tags":158},[42,159,160,161],"栈","队列","JavaScript",{"category":40,"tags":163},[42,164,161],"哈希表",{"category":5,"tags":166},[167,161,168],"ES6","Set",{"category":40,"tags":170},[40,171,172],"动态规划","背包问题",{"category":40,"tags":174},[40,175],"滑动窗口",{"category":40,"tags":177},[42,178,161],"链表",{"category":40,"tags":180},[40,181,161,182],"ACM","面试",{"category":40,"tags":184},[40,161,185],"数据类型转换",{"category":40,"tags":187},[42,188,178,189],"顺序表","TypeScript",{"category":5,"tags":191},[135,154,134,192],"渲染",{"category":5,"tags":194},[135,195,134,196],"富文本编辑","wangEditor",{"category":40,"tags":198},[199,40,178],"力扣",{"category":5,"tags":201},[202,203,204,91],"DNS","加密","网络安全",{"category":5,"tags":206},[135,207],"Axios",{"category":5,"tags":209},[135,207,189,210],"接口封装",{"category":5,"tags":212},[135,213,145],"JWT",{"category":152,"tags":215},[134,216,217,154,218],"画布","React","项目开发",{"category":17,"tags":220},[221],"日志",{"category":5,"tags":223},[224,225,226,227],"Neo4j","图数据库","中心度分析","PageRank",{"category":12,"tags":229},[230,231,232,233],"论文","机器学习","随机森林","保险欺诈",{"category":5,"tags":235},[236],"人工智能",{"category":5,"tags":238},[231,40,32],{"category":12,"tags":240},[241,242,243,244],"共识算法","最长链","区块链","算法设计",{"category":5,"tags":246},[161,247,248,135],"闭包","作用域",{"category":5,"tags":250},[14,138,251,252],"RSS","自动化",{"category":30,"tags":254},[89,90,91,255],"安装",{"category":30,"tags":257},[258,91,259],"MSYS2","开发环境",{"category":5,"tags":261},[262,263,264,265],"团队协作","Git","Commit规范","代码审查",{"category":30,"tags":267},[268,269,270],"oh-my-posh","终端美化","CLI",{"category":5,"tags":272},[273,274,275],"上下文工程","Agent","RAG",{"category":5,"tags":277},[278,279,280],"前端工程化","技术选型","工程基建",{"category":5,"tags":282},[275],{"category":5,"tags":284},[285,286],"SDD","规格驱动开发",{"category":5,"tags":288},[289],"neovim",{"category":5,"tags":291},[292],"技术写作",{"category":5,"tags":294},[295,32,296,89,297],"FastAPI","后端","工程实践",{"category":152,"tags":299},[274,300],"Harness Engineering",{"category":5,"tags":302},[303],"RFC",{"category":152,"tags":305},[274,300,275],{"category":152,"tags":307},[274],{"category":40,"tags":309},[199,40,161,310],"两数之和",{"category":40,"tags":312},[199,40,161,313],"双指针",{"category":40,"tags":315},[199,40,161,178],{"category":40,"tags":317},[199,40,161,175],{"category":40,"tags":319},[199,40,161,320],"回溯",{"category":5,"tags":322},[323,324,325,326],"分布式处理与计算","Spark","Scala","RDD",{"category":5,"tags":328},[323,329,330,331],"线性回归","逻辑回归","监督学习",{"category":5,"tags":333},[323,334,335,336],"数值优化","牛顿法","收敛性",{"category":5,"tags":338},[323,339,340,341],"PCA","数据降维","正则化",{"category":5,"tags":343},[323,344,345,346],"聚类","层次聚类","K-Means",{"category":5,"tags":348},[323,349,350,351],"随机模拟","统计推断","EM算法",{"category":5,"tags":353},[24],{"category":5,"tags":355},[24],{"category":5,"tags":357},[24],{"category":40,"tags":359},[42,360],"图论",{"category":40,"tags":362},[42,363,364],"树","二叉树",{"category":40,"tags":366},[42,160],{"category":40,"tags":368},[42],{"category":40,"tags":370},[42,159],{"category":40,"tags":372},[42,40,373],"复杂度分析",{"category":40,"tags":375},[42,43,178],{"category":40,"tags":377},[42,43,188],{"category":40,"tags":379},[42,380],"基础概念",{"category":17,"tags":382},[383,384,385],"随记","照片","高中",{"category":17,"tags":387},[383,388,389],"团建","战地",{"category":17,"tags":391},[383,392],"生活",{"category":17,"tags":394},[383,395],"文学",{"category":17,"tags":397},[383,398,399],"SCP","模因",{"category":17,"tags":401},[383,402,403],"画集","艺术",{"category":17,"tags":405},[14,406,383],"写作",{"category":17,"tags":408},[383,409,410],"音乐","摇滚",{"category":17,"tags":412},[383,392],{"category":17,"tags":414},[383,409],{"category":17,"tags":416},[383,35,392],{"category":17,"tags":418},[383,419,420],"同人","手书",{"category":17,"tags":422},[383,392],{"category":17,"tags":424},[383,392],{"category":17,"tags":426},[383,427],"植物",{"category":17,"tags":429},[383,430],"读书",{"category":17,"tags":432},[383,392],{"category":17,"tags":434},[383,392],{"category":17,"tags":436},[383,392],{"category":17,"tags":438},[383,392],{"category":17,"tags":440},[383,392],{"category":17,"tags":442},[383,392],{"category":17,"tags":444},[14,445,383],"一周年",{"category":17,"tags":447},[383,392],{"category":17,"tags":449},[383,392],{"category":17,"tags":451},[35,452,453],"开源组织","SITE-193",{"category":17,"tags":455},[383,392],{"category":17,"tags":457},[383,458],"年末",{"category":17,"tags":460},[383,392],{"category":17,"tags":462},[383,392],{"category":17,"tags":464},[14,17],{"category":17,"tags":466},[383,392],{"category":17,"tags":468},[383,392],{"category":17,"tags":470},[383,392],{"category":17,"tags":472},[383,392],{"category":17,"tags":474},[383,475,476],"游戏","Control",{"category":30,"tags":478},[479,480,481,482],"Quadim","图像处理","四叉树","Rust",{"category":17,"tags":484},[14,485],"上线纪念",{"category":17,"tags":487},[383,395],{"category":30,"tags":489},[203,490],"测试",{"category":17,"tags":492},[383],{"category":17,"tags":494},[383,392],{"category":17,"tags":496},[383],{"category":5,"tags":498},[40],{"category":5,"tags":500},[40],{"category":5,"tags":502},[40],{"category":5,"tags":504},[40],{"category":5,"tags":506},[40],[508,512,516,520,524,528,532,536,540,544,548,552,556,560,564,568,572,576,579,583,587,590,594,598,602,606,610,614,618,622,626,630,634,638,642,646,650,654,658,662,666,670,674,678,682,686,690,694,698,702,706,710,714,718,722,726,730,734,738,742,746,750,754,758,762,766,770,774,778,782,786,790,794,798,801,805,809,813,817,821,825,829,833,837,841,845,849,853,857,861,865,869,873,877,881,885,889,893,897,901,904,907,911,915,919,923,927,930,934,938,942,946,950,954,957,961,965,969,973,977,981,984,988,992,996,1000,1004,1007,1011,1015,1019,1023,1027,1031,1035,1039,1043,1047,1051,1055,1059,1063,1067,1071,1075,1079],{"path":509,"words":510,"published":511,"date":156},"\u002Fposts\u002F2024\u002F2024-06-25-liteloaderqqnt",1296,"2024-06-25 11:03:08",{"path":513,"words":514,"published":515,"date":156},"\u002Fposts\u002F2024\u002F2024-06-26-ip签名",0,"2024-06-26 22:53:53",{"path":517,"words":518,"published":519,"date":156},"\u002Fposts\u002F2024\u002F2024-06-29-何加盐中文互联网正在加速崩塌",4853,"2024-06-29 00:47:13",{"path":521,"words":522,"published":523,"date":156},"\u002Fposts\u002F2024\u002F2024-07-12-github-chart",270,"2024-07-12 20:02:50",{"path":525,"words":526,"published":527,"date":156},"\u002Fposts\u002F2024\u002F2024-07-14-访客数统计",192,"2024-07-24 01:34:28",{"path":529,"words":530,"published":531,"date":156},"\u002Fposts\u002F2024\u002F2024-07-26-广大选课脚本",110,"2024-07-26 01:34:28",{"path":533,"words":534,"published":535,"date":156},"\u002Fposts\u002F2024\u002F2024-07-26-数学分析笔记其二",14,"2024-07-26 23:54:02",{"path":537,"words":538,"published":539,"date":156},"\u002Fposts\u002F2024\u002F2024-07-26-数据结构复习其二",12856,"2024-07-26 02:48:08",{"path":541,"words":542,"published":543,"date":156},"\u002Fposts\u002F2024\u002F2024-07-26-数据结构复习相关",32856,"2024-07-26 02:24:14",{"path":545,"words":546,"published":547,"date":156},"\u002Fposts\u002F2024\u002F2024-07-26-线性代数与空间解析几何知识点全汇总",6110,"2024-07-26 03:27:55",{"path":549,"words":550,"published":551,"date":156},"\u002Fposts\u002F2024\u002F2024-07-31-typora-picgo-兰空图床打造markdown写作环境",311,"2024-07-31 12:56:23",{"path":553,"words":554,"published":555,"date":156},"\u002Fposts\u002F2024\u002F2024-09-02-jupyter配置中文",441,"2024-09-02 23:49:05",{"path":557,"words":558,"published":559,"date":156},"\u002Fposts\u002F2024\u002F2024-09-02-自定义vscode背景图片",100,"2024-09-02 23:39:00",{"path":561,"words":562,"published":563,"date":156},"\u002Fposts\u002F2024\u002F2024-09-03-制作gzhu校园网路由器",1423,"2024-09-03 23:43:18",{"path":565,"words":566,"published":567,"date":156},"\u002Fposts\u002F2024\u002F2024-09-26-09-25-2024",136,"2024-09-26 00:49:26",{"path":569,"words":570,"published":571,"date":156},"\u002Fposts\u002F2024\u002F2024-11-04-创建一个网络包解码器分析devp2p协议",1943,"2024-11-04 16:45:19",{"path":573,"words":574,"published":575,"date":156},"\u002Fposts\u002F2024\u002F2025-03-09-2024一些笔记常微分",16,"2024-12-09 22:51:00",{"path":577,"words":574,"published":578,"date":156},"\u002Fposts\u002F2024\u002F2025-03-09-2024一些笔记概率论","2025-03-09 22:51:00",{"path":580,"words":581,"published":582,"date":156},"\u002Fposts\u002F2025\u002F2025-03-01-windows下释放docker占用的wsl空间",164,"2025-03-01 13:54:15",{"path":584,"words":585,"published":586,"date":156},"\u002Fposts\u002F2025\u002F2025-03-01-爬虫实战-爬取广州大学课程表",774,"2025-03-01 14:55:48",{"path":588,"words":530,"published":589,"date":156},"\u002Fposts\u002F2025\u002F2025-04-03-hexo集成gitalk的问题","2025-04-03 14:26:34",{"path":591,"words":592,"published":593,"date":156},"\u002Fposts\u002F2025\u002F2025-09-22-记一次配置obsidian配合hexo写博客",92,"2025-09-22 16:47:52",{"path":595,"words":596,"published":597,"date":156},"\u002Fposts\u002F2025\u002F2025-10-24-10-20课题",204,"2025-10-24 08:15:01",{"path":599,"words":600,"published":601,"date":156},"\u002Fposts\u002F2025\u002F2025-11-02-组会朝花夕拾",501,"2025-11-02 18:56:59",{"path":603,"words":604,"published":605,"date":156},"\u002Fposts\u002F2025\u002F2025-11-05-基于随机加权推断模型im的参数估计算法",481,"2025-11-05 20:51:41",{"path":607,"words":608,"published":609,"date":156},"\u002Fposts\u002F2025\u002F2025-11-09-关于软考高项网规",884,"2025-11-09 19:54:52",{"path":611,"words":612,"published":613,"date":156},"\u002Fposts\u002F2025\u002F2025-11-17-arch-linux运行appimage相关",365,"2025-11-17 09:59:59",{"path":615,"words":616,"published":617,"date":156},"\u002Fposts\u002F2025\u002F2025-11-19-相关概念",2058,"2025-11-19 11:06:20",{"path":619,"words":620,"published":621,"date":156},"\u002Fposts\u002F2025\u002F2025-11-21-web可视化实践canvas",6393,"2025-11-21 14:50:48",{"path":623,"words":624,"published":625,"date":156},"\u002Fposts\u002F2025\u002F2025-11-22-使用-github-actions-自动部署基于vite的项目到-github-pages",272,"2025-11-23 01:59:24",{"path":627,"words":628,"published":629,"date":156},"\u002Fposts\u002F2025\u002F2025-11-22-关于前端包管理器npmpnpmyarn和bun",4890,"2025-11-22 21:21:15",{"path":631,"words":632,"published":633,"date":156},"\u002Fposts\u002F2025\u002F2025-11-23-undo-redo-机制具体实现",7839,"2025-11-23 19:15:00",{"path":635,"words":636,"published":637,"date":156},"\u002Fposts\u002F2025\u002F2025-11-27-前端安全-关于xss与crsf",1866,"2025-11-28 02:05:25",{"path":639,"words":640,"published":641,"date":156},"\u002Fposts\u002F2025\u002F2025-12-05-现代协同-2d-画布编辑器pixijs-v8",4285,"2025-12-05 02:00:16",{"path":643,"words":644,"published":645,"date":156},"\u002Fposts\u002F2025\u002F2025-12-07-杂谈-课题组系统设计",3710,"2025-12-07 21:09:33",{"path":647,"words":648,"published":649,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-javascript中的数组方法与栈stack和队列queue的实现",474,"2025-11-29 16:58:15",{"path":651,"words":652,"published":653,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-关于javascript-实现哈希表",189,"2025-11-27 11:31:39",{"path":655,"words":656,"published":657,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-关于javascript的set方法",332,"2025-12-25 15:26:18",{"path":659,"words":660,"published":661,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-关于动态规划(背包问题为例)",1238,"2025-11-17 18:24:04",{"path":663,"words":664,"published":665,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-关于滑动窗口",712,"2025-11-20 15:04:40",{"path":667,"words":668,"published":669,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-关于链表(javascript)",500,"2025-11-25 13:00:48",{"path":671,"words":672,"published":673,"date":156},"\u002Fposts\u002F2025\u002F2025-12-25-面试算法acm模式构建构建输入输出模板",2051,"2025-12-25 23:09:45",{"path":675,"words":676,"published":677,"date":156},"\u002Fposts\u002F2025\u002F2025-12-26-javascript-数字数组字符串的处理",483,"2025-12-26 11:20:25",{"path":679,"words":680,"published":681,"date":156},"\u002Fposts\u002F2025\u002F2025-12-26-javascripttypescript-的顺序表链表实现",446,"2025-12-26 12:16:12",{"path":683,"words":684,"published":685,"date":156},"\u002Fposts\u002F2025\u002F2025-12-27-前端画布设计vol-1-实现基础yuan素渲染和状态控制",2136,"2025-12-28 01:23:08",{"path":687,"words":688,"published":689,"date":156},"\u002Fposts\u002F2025\u002F2025-12-27-前端画布设计vol-2-实现富文本编辑",1838,"2025-12-28 02:58:47",{"path":691,"words":692,"published":693,"date":156},"\u002Fposts\u002F2025\u002F2025-12-27-算法刷题-关于链表操作",2135,"2025-12-27 16:12:42",{"path":695,"words":696,"published":697,"date":156},"\u002Fposts\u002F2025\u002F2025-12-27-配置dnscrypt-proxy实现加密dns服务windows",1899,"2025-11-15 01:06:37",{"path":699,"words":700,"published":701,"date":156},"\u002Fposts\u002F2025\u002F2025-12-28-前端-关于网络请求xhrajaxfetchaxios",7788,"2025-10-28 15:48:46",{"path":703,"words":704,"published":705,"date":156},"\u002Fposts\u002F2025\u002F2025-12-28-前端-接口封装与请求规范axios为例",2373,"2025-12-29 01:01:18",{"path":707,"words":708,"published":709,"date":156},"\u002Fposts\u002F2025\u002F2025-12-28-前端-身份验证管理-基于-jwt-token-的实现",2709,"2025-11-23 22:04:41",{"path":711,"words":712,"published":713,"date":156},"\u002Fposts\u002F2025\u002F2025-12-29-记canvas画布项目开发",6254,"2025-12-29 04:52:15",{"path":715,"words":716,"published":717,"date":156},"\u002Fposts\u002F2025\u002Fabout-site",116,"2025-03-31 16:44:24",{"path":719,"words":720,"published":721,"date":156},"\u002Fposts\u002F2025\u002F使用neo4j图数据科学库gds进行中心度分析",143,"2025-04-02 23:37:24",{"path":723,"words":724,"published":725,"date":156},"\u002Fposts\u002F2026\u002F2026-01-03-论文实训草稿",10445,"2026-01-03 14:10:49",{"path":727,"words":728,"published":729,"date":156},"\u002Fposts\u002F2026\u002F2026-01-06-人工智能导论",11461,"2026-01-07 03:25:49",{"path":731,"words":732,"published":733,"date":156},"\u002Fposts\u002F2026\u002F2026-01-07-机器学习相关算法",82,"2026-01-08 02:28:13",{"path":735,"words":736,"published":737,"date":156},"\u002Fposts\u002F2026\u002F2026-01-16-关于低复杂度最长链共识算法设计",4466,"2026-01-16 10:33:23",{"path":739,"words":740,"published":741,"date":156},"\u002Fposts\u002F2026\u002F2026-01-19-杂记2026-01-19",5754,"2026-01-20 01:03:26",{"path":743,"words":744,"published":745,"date":156},"\u002Fposts\u002F2026\u002F2026-01-22-github-action-自动同步博客到-github-主页",1069,"2026-01-22 16:31:47",{"path":747,"words":748,"published":749,"date":156},"\u002Fposts\u002F2026\u002F2026-03-01-windows-wsl安装docker",257,"2026-03-01 20:55:31",{"path":751,"words":752,"published":753,"date":156},"\u002Fposts\u002F2026\u002F2026-03-14-关于-msys2",701,"2026-03-15 03:05:04",{"path":755,"words":756,"published":757,"date":156},"\u002Fposts\u002F2026\u002F2026-03-28-团队项目协作规范随记",2419,"2026-03-29 05:33:50",{"path":759,"words":760,"published":761,"date":156},"\u002Fposts\u002F2026\u002F2026-03-29-oh-my-posh配置分享",104,"2026-03-29 16:34:43",{"path":763,"words":764,"published":765,"date":156},"\u002Fposts\u002F2026\u002F2026-06-13-从上下文工程到-agent-harness-engineering",8840,"2026-06-13 21:06:20",{"path":767,"words":768,"published":769,"date":156},"\u002Fposts\u002F2026\u002F2026-07-13-简谈前端基建质量标准与ai友好建设",3814,"2026-07-13 21:06:20",{"path":771,"words":772,"published":773,"date":156},"\u002Fposts\u002F2026\u002F2026-07-15-agentic-rag-系统实践",13357,"2026-07-15 21:06:20",{"path":775,"words":776,"published":777,"date":156},"\u002Fposts\u002F2026\u002F2026-07-18-简谈sdd规范驱动开发-copy",4476,"2026-07-18 11:06:20",{"path":779,"words":780,"published":781,"date":156},"\u002Fposts\u002F2026\u002F2026-07-20-个人neovim配置分享",366,"2026-07-22 13:46:50",{"path":783,"words":784,"published":785,"date":156},"\u002Fposts\u002F2026\u002F2026-07-20-关于技术写作-设计文档",6763,"2026-07-20 13:46:50",{"path":787,"words":788,"published":789,"date":156},"\u002Fposts\u002F2026\u002F2026-07-29-fastapi-工程实践随记",3417,"2026-07-29 04:58:34",{"path":791,"words":792,"published":793,"date":156},"\u002Fposts\u002Frfc_agentic_project\u002F2026-06-19-rfc-liskin_code_agent",3563,"2026-06-19 15:18:20",{"path":795,"words":796,"published":797,"date":156},"\u002Fposts\u002Frfc_agentic_project\u002F2026-07-25-rfc-glushkov_ogas",13461,"2026-07-28 23:46:50",{"path":799,"words":800,"published":793,"date":156},"\u002Fposts\u002Frfc_agentic_project\u002F2026-08-03-adr-ogas-arkhiv",2698,{"path":802,"words":803,"published":804,"date":156},"\u002Fposts\u002Frfc_agentic_project\u002Frag\u002F2026-08-12-arkhiv_rag-vol-1",4366,"2026-08-12 15:18:20",{"path":806,"words":807,"published":808,"date":156},"\u002Fposts\u002Fleetcode\u002F2025-12-25-力扣百题速练javascripttypescriptvol-1",3108,"2025-12-25 13:57:08",{"path":810,"words":811,"published":812,"date":156},"\u002Fposts\u002Fleetcode\u002F2025-12-26-力扣百题速练javascripttypescriptvol-2",3185,"2025-12-26 13:57:08",{"path":814,"words":815,"published":816,"date":156},"\u002Fposts\u002Fleetcode\u002F2025-12-28-力扣百题速练javascripttypescriptvol-3",2533,"2025-12-29 02:15:44",{"path":818,"words":819,"published":820,"date":156},"\u002Fposts\u002Fleetcode\u002F2026-01-17-力扣百题速练javascripttypescriptvol-4",3024,"2026-01-17 22:53:01",{"path":822,"words":823,"published":824,"date":156},"\u002Fposts\u002Fleetcode\u002F2026-01-19-力扣百题速练javascripttypescriptvol-5",293,"2026-01-19 15:15:12",{"path":826,"words":827,"published":828,"date":156},"\u002Fposts\u002F分布式处理与计算\u002F分布式处理与计算spark系统与scala语言",4320,"2026-07-06 14:10:49",{"path":830,"words":831,"published":832,"date":156},"\u002Fposts\u002F分布式处理与计算\u002F分布式处理与计算分类与回归分析",2081,"2026-07-07 12:40:29",{"path":834,"words":835,"published":836,"date":156},"\u002Fposts\u002F分布式处理与计算\u002F分布式处理与计算数值优化方法",2182,"2026-07-07 13:50:29",{"path":838,"words":839,"published":840,"date":156},"\u002Fposts\u002F分布式处理与计算\u002F分布式处理与计算数据降维",1583,"2026-07-07 21:50:29",{"path":842,"words":843,"published":844,"date":156},"\u002Fposts\u002F分布式处理与计算\u002F分布式处理与计算聚类分析",1882,"2026-07-07 16:50:29",{"path":846,"words":847,"published":848,"date":156},"\u002Fposts\u002F分布式处理与计算\u002F分布式处理与计算随机模拟与统计推断",2433,"2026-07-06 22:40:29",{"path":850,"words":851,"published":852,"date":156},"\u002Fposts\u002F数据可视化\u002F2026-06-14-数据可视化-基本图像",10011,"2026-06-14 20:18:20",{"path":854,"words":855,"published":856,"date":156},"\u002Fposts\u002F数据可视化\u002F2026-06-14-数据可视化-概论",10030,"2026-06-14 15:18:20",{"path":858,"words":859,"published":860,"date":156},"\u002Fposts\u002F数据可视化\u002F2026-06-15-数据可视化-可视化库",2032,"2026-06-15 20:18:20",{"path":862,"words":863,"published":864,"date":156},"\u002Fposts\u002F数据结构\u002F2025-09-29-数据结构-图",5451,"2025-04-15 20:48:06",{"path":866,"words":867,"published":868,"date":156},"\u002Fposts\u002F数据结构\u002F2025-09-29-数据结构-树",4211,"2025-04-30 02:19:06",{"path":870,"words":871,"published":872,"date":156},"\u002Fposts\u002F数据结构\u002F2025-09-29-数据结构-队列",921,"2025-04-11 15:30:08",{"path":874,"words":875,"published":876,"date":156},"\u002Fposts\u002F数据结构\u002F关于数据结构的一些想法",456,"2025-04-04 19:13:49",{"path":878,"words":879,"published":880,"date":156},"\u002Fposts\u002F数据结构\u002F数据结构-栈",920,"2025-04-18 00:17:37",{"path":882,"words":883,"published":884,"date":156},"\u002Fposts\u002F数据结构\u002F数据结构-算法复杂度",2153,"2025-04-05 16:09:55",{"path":886,"words":887,"published":888,"date":156},"\u002Fposts\u002F数据结构\u002F数据结构-线性表链表",4572,"2025-04-06 00:24:40",{"path":890,"words":891,"published":892,"date":156},"\u002Fposts\u002F数据结构\u002F数据结构-线性表顺序表",4191,"2025-04-05 22:09:59",{"path":894,"words":895,"published":896,"date":156},"\u002Fposts\u002F数据结构\u002F数据结构-绪论",2881,"2025-04-05 11:31:22",{"path":898,"words":899,"published":156,"date":900},"\u002Fposts\u002F杂记\u002F2023-12-26-2023年12月26日",7,"2023-12-26 23:51:33",{"path":902,"words":899,"published":156,"date":903},"\u002Fposts\u002F杂记\u002F2024-01-01-2024团建","2024-01-01 01:51:27",{"path":905,"words":581,"published":156,"date":906},"\u002Fposts\u002F杂记\u002F2024-01-09-随记","2024-01-09 20:50:35",{"path":908,"words":909,"published":156,"date":910},"\u002Fposts\u002F杂记\u002F2024-03-12-花名",5275,"2024-03-12 16:04:23",{"path":912,"words":913,"published":156,"date":914},"\u002Fposts\u002F杂记\u002F2024-04-15-逆模因",1353,"2024-04-15 00:26:16",{"path":916,"words":917,"published":156,"date":918},"\u002Fposts\u002F杂记\u002F2024-05-03-艺作",27,"2024-05-03 00:01:00",{"path":920,"words":921,"published":156,"date":922},"\u002Fposts\u002F杂记\u002F2024-05-21-关于博客",1720,"2024-02-21 23:21:11",{"path":924,"words":925,"published":156,"date":926},"\u002Fposts\u002F杂记\u002F2024-06-20-dnvrc维克多崔专访-2023",2036,"2024-06-20 00:28:39",{"path":928,"words":760,"published":156,"date":929},"\u002Fposts\u002F杂记\u002F2024-06-25-6月25日随记","2024-06-25 12:46:36",{"path":931,"words":932,"published":156,"date":933},"\u002Fposts\u002F杂记\u002F2024-06-25-歌名-想見星星",148,"2024-06-25 22:47:59",{"path":935,"words":936,"published":156,"date":937},"\u002Fposts\u002F杂记\u002F2024-07-11-桂花岗",6,"2024-07-11 00:28:17",{"path":939,"words":940,"published":156,"date":941},"\u002Fposts\u002F杂记\u002F2024-07-15-rolling-bocci",9,"2024-07-15 21:25:56",{"path":943,"words":944,"published":156,"date":945},"\u002Fposts\u002F杂记\u002F2024-07-22-七月九日",34,"2024-07-09 17:54:23",{"path":947,"words":948,"published":156,"date":949},"\u002Fposts\u002F杂记\u002F2024-07-22-六月随记",1052,"2024-07-22 01:03:19",{"path":951,"words":952,"published":156,"date":953},"\u002Fposts\u002F杂记\u002F2024-07-22-石菖蒲",41,"2024-07-10 01:26:05",{"path":955,"words":514,"published":156,"date":956},"\u002Fposts\u002F杂记\u002F2024-07-23-新书","2024-07-23 01:05:49",{"path":958,"words":959,"published":156,"date":960},"\u002Fposts\u002F杂记\u002F2024-07-29-年前",74,"2024-07-29 23:59:07",{"path":962,"words":963,"published":156,"date":964},"\u002Fposts\u002F杂记\u002F2024-07-30-周边",138,"2024-07-30 02:03:23",{"path":966,"words":967,"published":156,"date":968},"\u002Fposts\u002F杂记\u002F2024-07-31-2024-7-31日",185,"2024-07-31 01:21:52",{"path":970,"words":971,"published":156,"date":972},"\u002Fposts\u002F杂记\u002F2024-08-02-八月",1710,"2024-08-30 00:31:02",{"path":974,"words":975,"published":156,"date":976},"\u002Fposts\u002F杂记\u002F2024-09-01-九月",1484,"2024-09-26 16:30:15",{"path":978,"words":979,"published":156,"date":980},"\u002Fposts\u002F杂记\u002F2024-09-08-2024年9月8日",97,"2024-09-08 13:59:36",{"path":982,"words":514,"published":156,"date":983},"\u002Fposts\u002F杂记\u002F2024-09-19-博客站的一周年","2024-09-18 23:36:24",{"path":985,"words":986,"published":156,"date":987},"\u002Fposts\u002F杂记\u002F2024-10-03-十月",1028,"2024-10-31 23:54:27",{"path":989,"words":990,"published":156,"date":991},"\u002Fposts\u002F杂记\u002F2024-11-04-十一月",2134,"2024-11-30 00:04:39",{"path":993,"words":994,"published":156,"date":995},"\u002Fposts\u002F杂记\u002F2024-11-10-gzhu-site-193",80,"2024-11-10 19:19:12",{"path":997,"words":998,"published":156,"date":999},"\u002Fposts\u002F杂记\u002F2024-12-01-十二月",455,"2024-12-30 23:13:47",{"path":1001,"words":1002,"published":156,"date":1003},"\u002Fposts\u002F杂记\u002F2024-12-21-年末",23,"2024-12-21 23:21:49",{"path":1005,"words":522,"published":156,"date":1006},"\u002Fposts\u002F杂记\u002F2025-01-30-一月","2025-01-30 23:44:41",{"path":1008,"words":1009,"published":156,"date":1010},"\u002Fposts\u002F杂记\u002F2025-03-01-二月",224,"2025-02-28 00:11:08",{"path":1012,"words":1013,"published":156,"date":1014},"\u002Fposts\u002F杂记\u002F2025-03-01-博客杂谈",344,"2025-03-01 16:03:18",{"path":1016,"words":1017,"published":156,"date":1018},"\u002Fposts\u002F杂记\u002F2025-03-05-三月",2090,"2025-03-05 19:17:28",{"path":1020,"words":1021,"published":156,"date":1022},"\u002Fposts\u002F杂记\u002F2025-07-10-七月",361,"2025-07-10 22:04:32",{"path":1024,"words":1025,"published":156,"date":1026},"\u002Fposts\u002F杂记\u002F2025-09-21-九月",781,"2025-09-21 20:53:37",{"path":1028,"words":1029,"published":156,"date":1030},"\u002Fposts\u002F杂记\u002F2025-10-09-十月",591,"2025-10-09 00:35:07",{"path":1032,"words":1033,"published":156,"date":1034},"\u002Fposts\u002F杂记\u002Fcontrol",13,"2023-09-25 13:03:59",{"path":1036,"words":1037,"published":156,"date":1038},"\u002Fposts\u002F杂记\u002Fquadim四叉树图像处理",1094,"2023-10-21 11:27:06",{"path":1040,"words":1041,"published":156,"date":1042},"\u002Fposts\u002F杂记\u002Fsuccess",24,"2023-09-22 15:59:07",{"path":1044,"words":1045,"published":156,"date":1046},"\u002Fposts\u002F杂记\u002F三光年",295,"2023-11-18 04:18:40",{"path":1048,"words":1049,"published":156,"date":1050},"\u002Fposts\u002F杂记\u002F加密测试",961,"2023-11-21 14:24:01",{"path":1052,"words":1053,"published":156,"date":1054},"\u002Fposts\u002F杂记\u002F啊啊啊啊啊啊啊",54,"2023-09-20 17:29:33",{"path":1056,"words":1057,"published":156,"date":1058},"\u002Fposts\u002F杂记\u002F杂谈",124,"2023-09-25 23:11:32",{"path":1060,"words":1061,"published":156,"date":1062},"\u002Fposts\u002F杂记\u002F艹",25,"2023-10-21 17:40:43",{"path":1064,"words":1065,"published":1066,"date":156},"\u002Fposts\u002F神经网络\u002F神经网络与深度学习卷积神经网络",3168,"2026-06-07 11:23:08",{"path":1068,"words":1069,"published":1070,"date":156},"\u002Fposts\u002F神经网络\u002F神经网络与深度学习循环神经网络",2348,"2026-06-07 11:33:08",{"path":1072,"words":1073,"published":1074,"date":156},"\u002Fposts\u002F神经网络\u002F神经网络与深度学习改进学习方法",3360,"2026-06-07 10:13:08",{"path":1076,"words":1077,"published":1078,"date":156},"\u002Fposts\u002F神经网络\u002F神经网络与深度学习深度生成模型",2727,"2026-06-07 11:43:08",{"path":1080,"words":1081,"published":1082,"date":156},"\u002Fposts\u002F神经网络\u002F神经网络与深度学习神经网络基础",3205,"2026-06-07 10:03:08",{"id":1084,"title":1085,"abbrlink":1086,"body":1087,"category":5,"cover":156,"date":156,"description":1097,"extension":1816,"mathjax":1817,"meta":1818,"navigation":1817,"path":631,"published":633,"readingMinutes":1821,"seo":1822,"stem":1823,"sticky":156,"swiper_index":156,"tags":1824,"updated":156,"words":632,"__hash__":1825},"posts\u002Fposts\u002F2025\u002F2025-11-23-Undo-Redo-机制具体实现.md","2025-11-23-简谈前端编辑器Undo\u002FRedo机制的一些实现","52695",{"type":1088,"value":1089,"toc":1797},"minimark",[1090,1094,1098,1101,1105,1108,1111,1115,1118,1174,1179,1182,1193,1231,1234,1238,1241,1247,1283,1327,1333,1337,1340,1344,1347,1350,1369,1372,1376,1379,1386,1390,1393,1400,1403,1414,1418,1436,1439,1442,1459,1462,1466,1469,1472,1475,1478,1482,1485,1489,1492,1499,1503,1506,1521,1532,1539,1543,1546,1554,1558,1576,1584,1595,1599,1609,1615,1626,1630,1633,1639,1642,1645,1651,1658,1661,1672,1678,1685,1689,1692,1703,1737,1743,1749,1756,1760,1767,1774,1781,1788,1791,1794],[1091,1092,1093],"h1",{"id":1093},"概述",[1095,1096,1097],"p",{},"撤销和重做几乎是所有编辑类产品的基础能力，但它恰恰是那种\"做一个能用的版本只要半天，做一个真正经得起折腾的版本要反复打磨好几个迭代\"的功能。无论你做的是图形画布（类似 Figma、白板、流程图）还是富文本编辑器（类似 Notion、飞书文档、各种 CMS 后台），背后的核心问题是一样的：如何精确地、可逆地描述用户对文档做的每一次改动，并且让这种回退在用户感知上是\"自然\"的。",[1095,1099,1100],{},"它的本质是对\"状态变化\"建模——你要么记录状态本身，要么记录引起状态变化的操作，要么记录两者之间的差异。本文主要就这些展开阐述",[1091,1102,1104],{"id":1103},"一撤销到底在撤销什么","一、\"撤销\"到底在撤销什么",[1095,1106,1107],{},"动手之前先注意一个问题：编辑器把\"文档状态\"建模成了什么？撤销的本质就是在状态空间里来回移动，所以状态的建模方式直接决定了 undo\u002Fredo 的实现难度。",[1095,1109,1110],{},"比如说文档是一棵结构化的数据树（比如画布里的图元数组、富文本里的文档节点树），那么撤销就是让这棵树回到上一个版本。如果你的文档状态散落在各处——一部分在 React state 里，一部分在 DOM 里，一部分在某个全局单例里——那么撤销就会变成一场灾难，因为你永远没法保证回退是完整的。于是做撤销写撤销逻辑之前，需要先把文档状态收敛成一个清晰、可序列化、单一来源的数据结构。",[1091,1112,1114],{"id":1113},"二几种主流思路","二、几种主流思路",[1095,1116,1117],{},"撤销机制大体上分多个流派",[1119,1120,1121,1137],"table",{},[1122,1123,1124],"thead",{},[1125,1126,1127,1131,1134],"tr",{},[1128,1129,1130],"th",{},"方案",[1128,1132,1133],{},"描述",[1128,1135,1136],{},"适用场景",[1138,1139,1140,1152,1163],"tbody",{},[1125,1141,1142,1146,1149],{},[1143,1144,1145],"td",{},"全量快照（Memento）",[1143,1147,1148],{},"每步存一份完整 state",[1143,1150,1151],{},"状态小、操作少的工具",[1125,1153,1154,1157,1160],{},[1143,1155,1156],{},"命令模式（Command）",[1143,1158,1159],{},"存\"操作\"本身，靠 do\u002Fundo 反演",[1143,1161,1162],{},"画布、编辑器主流方案",[1125,1164,1165,1168,1171],{},[1143,1166,1167],{},"CRDT \u002F OT 历史模型",[1143,1169,1170],{},"操作可交换 \u002F 可变基",[1143,1172,1173],{},"多人实时协作",[1175,1176,1178],"h2",{"id":1177},"快照式memento-pattern","快照式（Memento Pattern）",[1095,1180,1181],{},"每次改动后，把整个文档状态（或它的不可变副本）压进一个栈里。撤销就是弹出当前状态、恢复上一个状态；重做则反过来。它的好处是实现极其简单，逻辑几乎不会出错，因为你存的就是\"完整的真相\"，恢复时不需要任何推理。缺点也很直接：如果文档很大，每存一份快照都很占内存。不过在配合不可变数据结构（Immer、Immutable.js 这类结构共享方案）之后，这个缺点被大幅缓解了——两个相邻快照之间没变的部分会共享同一份内存，只有改动的路径会产生新对象，所以一份\"快照\"实际占用的增量内存往往很小。对绝大多数中小型编辑器，我现在更倾向于推荐快照式作为起点，因为它把出错的可能性降到了最低。",[1183,1184,1190],"pre",{"className":1185,"code":1187,"language":1188,"meta":1189},[1186],"language-typescript","class SnapshotHistory\u003CT> {\n    private undo: T[] = [];\n    private redo: T[] = [];\n    constructor(\n        private getState: () => T,\n        private setState: (s: T) => void,\n    ) {}\n\n    commit() {\n        this.undo.push(structuredClone(this.getState()));\n        this.redo = [];\n    }\n    undoOnce() {\n        if (!this.undo.length) return;\n        this.redo.push(structuredClone(this.getState()));\n        this.setState(this.undo.pop()!);\n    }\n    redoOnce() {\n        if (!this.redo.length) return;\n        this.undo.push(structuredClone(this.getState()));\n        this.setState(this.redo.pop()!);\n    }\n}\n","typescript","",[1191,1192,1187],"code",{"__ignoreMap":1189},[1119,1194,1195,1205],{},[1122,1196,1197],{},[1125,1198,1199,1202],{},[1128,1200,1201],{},"优点",[1128,1203,1204],{},"缺点",[1138,1206,1207,1215,1223],{},[1125,1208,1209,1212],{},[1143,1210,1211],{},"实现极简，几十行搞定",[1143,1213,1214],{},"内存随历史线性增长",[1125,1216,1217,1220],{},[1143,1218,1219],{},"任意状态都能恢复，无需逐操作设计",[1143,1221,1222],{},"大对象深拷贝有性能压力",[1125,1224,1225,1228],{},[1143,1226,1227],{},"不依赖业务语义",[1143,1229,1230],{},"不利于协同（快照难合并）",[1095,1232,1233],{},"这个方案就比较适合状态体积小（表单、配置项、简单工具栏）、历史条数有上限（如 ≤ 50）、且不需要\"局部撤销\"或多人协作的场景。",[1175,1235,1237],{"id":1236},"_22-命令式command-pattern","2.2 命令式（Command Pattern）",[1095,1239,1240],{},"不存状态的话，而是把每一次改动描述成一个\"操作对象\"，这个对象知道怎么把自己应用上去（apply \u002F do），也知道怎么把自己撤销掉（invert \u002F undo）。比如\"把图元 A 从坐标 (10,10) 移动到 (50,50)\"这个操作，它的撤销就是\"把 A 移回 (10,10)\"。撤销栈里存的是这些操作而不是状态。它的优势是内存占用极小，每个操作通常只是几个字段；而且它天然适合协同编辑，因为操作本身就是可以被序列化、传输、变换的最小单元。于是实现复杂度就高得多，必须为每一种编辑动作都写出正确的逆操作，一旦某个逆操作写错或写漏，撤销就会让文档进入一个不一致的脏状态，而这类 bug 往往很隐蔽、很难复现。",[1183,1242,1245],{"className":1243,"code":1244,"language":1188,"meta":1189},[1186],"interface Command {\n    execute(): void;\n    undo(): void;\n    redo(): void;\n}\n\nclass AddElementCommand implements Command {\n    constructor(\n        private store: Store,\n        private el: CanvasElement,\n    ) {}\n    execute() {\n        this.store.add(this.el);\n    }\n    undo() {\n        this.store.remove(this.el.id);\n    }\n    redo() {\n        this.execute();\n    }\n}\n\nclass UpdateElementCommand implements Command {\n    constructor(\n        private store: Store,\n        private id: string,\n        private before: Partial\u003CEl>,\n        private after: Partial\u003CEl>,\n    ) {}\n    execute() {\n        this.store.patch(this.id, this.after);\n    }\n    undo() {\n        this.store.patch(this.id, this.before);\n    }\n    redo() {\n        this.execute();\n    }\n}\n",[1191,1246,1244],{"__ignoreMap":1189},[1119,1248,1249,1257],{},[1122,1250,1251],{},[1125,1252,1253,1255],{},[1128,1254,1201],{},[1128,1256,1204],{},[1138,1258,1259,1267,1275],{},[1125,1260,1261,1264],{},[1143,1262,1263],{},"内存小，只存\"差量参数\"",[1143,1265,1266],{},"每种操作要单独写命令类",[1125,1268,1269,1272],{},[1143,1270,1271],{},"语义清晰，便于打日志、回放、宏录制",[1143,1273,1274],{},"容易漏写 undo\u002Fredo 导致状态漂移",[1125,1276,1277,1280],{},[1143,1278,1279],{},"易于合并（如连续拖拽合并为一条）",[1143,1281,1282],{},"需要严格的\"前\u002F后状态\"约束",[1095,1284,1285,1286,1290,1291,1294,1295,1298,1299,1302,1303,1306,1307,1310,1311,1314,1315,1318,1319,1322,1323,1326],{},"落地命令模式时要关注几个细节\n",[1287,1288,1289],"strong",{},"执行时机一致性","：",[1191,1292,1293],{},"execute"," 与 ",[1191,1296,1297],{},"redo"," 必须等价，否则首次执行与重做行为会出现差异。\n",[1287,1300,1301],{},"锁机制","：执行 ",[1191,1304,1305],{},"undo\u002Fredo"," 期间，所有 ",[1191,1308,1309],{},"executeCommand"," 调用必须被忽略，避免历史污染。\n",[1287,1312,1313],{},"命令合并（coalescing）","：拖拽过程中可能产生上百条 ",[1191,1316,1317],{},"UpdateElementCommand","，应在 mouseup 时合并为一条。\n",[1287,1320,1321],{},"复合命令（composite）","：批量操作（多选删除）应聚合为一个 ",[1191,1324,1325],{},"CompositeCommand","，统一 do\u002Fundo。",[1183,1328,1331],{"className":1329,"code":1330,"language":1188,"meta":1189},[1186],"class CompositeCommand implements Command {\n    constructor(private children: Command[]) {}\n    execute() {\n        this.children.forEach((c) => c.execute());\n    }\n    undo() {\n        [...this.children].reverse().forEach((c) => c.undo());\n    }\n    redo() {\n        this.children.forEach((c) => c.redo());\n    }\n}\n",[1191,1332,1330],{"__ignoreMap":1189},[1175,1334,1336],{"id":1335},"_23-选型","2.3 选型",[1095,1338,1339],{},"实践中并不是非此即彼。一个常见的折中是命令式为主、关键节点穿插快照，这样既享受了操作式的低内存，又能在出问题时用快照\"兜底校正\"。但如果你的团队没有特别强的理由（比如必须做实时协同），我建议先用结构共享的快照式把产品跑起来，等真的遇到内存或协同瓶颈时再演进到命令式，而不是一上来就背上命令式的复杂度。至于第三类 CRDT \u002F OT 历史模型，它是为多人协作准备的，留到后面专门讲。",[1091,1341,1343],{"id":1342},"三撤销的颗粒度一次撤销应该回退多少","三、撤销的颗粒度：一次撤销应该回退多少",[1095,1345,1346],{},"这是最影响\"自然感\"的一点，也是最容易被忽略的一点。技术上每一次状态变更都可以是一个撤销单元，但用户心智里的\"一步\"和程序里的\"一次变更\"几乎从来不是一一对应的。",[1095,1348,1349],{},"举几个典型的例子。用户在文本里连续敲了一句话，他按下 Ctrl+Z 时期望的多半是删掉刚打的这一整个词或这一句，而不是一个字符一个字符地往回退；如果每个字符都是独立的撤销点，用户会按到崩溃。反过来，用户拖动一个图元时，鼠标移动过程中坐标可能变化了上百次，但这显然应该合并成\"移动了一次\"这一个撤销单元，而不是一百个。再比如，某些复合操作——\"删除一个段落\"在内部可能触发了\"移除节点 + 调整后续节点序号 + 更新目录\"等好几个子动作——对用户而言它就是一步，撤销时必须整体回退。",[1095,1351,1352,1353,1356,1357,1360,1361,1364,1365,1368],{},"解决这个问题的手段主要有三类。\n一是",[1287,1354,1355],{},"事务（transaction）","：用一个显式的边界把若干底层变更包成一个原子的撤销单元，比如 ",[1191,1358,1359],{},"history.batch(() => { ...一堆改动... })","，里面发生多少次变更都只产生一个撤销点，复合操作一律用事务包起来。\n二是",[1287,1362,1363],{},"合并（coalescing \u002F merging）","：对连续发生的同类操作做时间上的归并，典型策略是给操作打时间戳，如果新操作和栈顶操作类型相同、目标相同、且间隔小于某个阈值（比如几百毫秒到一秒），就把它们合并成一个，连续打字、连续拖动、连续调整滑块都适合这种处理。三是",[1287,1366,1367],{},"显式断点","：在某些语义边界主动切断合并，比如用户敲了空格、回车、或者把光标移到了别处，就强制结束当前的合并窗口，让下一次输入成为新的撤销点——这正是大多数文本编辑器\"按词撤销\"的实现方式。",[1095,1370,1371],{},"颗粒度需要拿真实使用场景去调，撤销单元应该对齐用户的意图，而不是对齐你的数据结构。",[1091,1373,1375],{"id":1374},"四选区和视图状态","四、选区和视图状态",[1095,1377,1378],{},"撤销有时候回退的不只是文档内容，还有用户当时的\"上下文\"。设想用户删掉了一段文字然后撤销，如果光标没有回到那段文字所在的位置、那段文字也没有重新被选中，用户就得自己去找\"我刚才在哪\"，这种体验是割裂的。在画布里也一样，撤销一次删除操作后，被恢复的图元最好重新处于选中态，并且视口最好能滚动到它可见的位置。",[1095,1380,1381,1382,1385],{},"所以撤销单元里除了文档变更，通常还应该一并记录",[1287,1383,1384],{},"操作前后的选区 \u002F 光标状态","。撤销时恢复\"操作前\"的选区，重做时恢复\"操作后\"的选区。这部分状态不属于文档本身，但它对自然感的贡献非常大，很多团队是在用户反馈\"撤销之后找不着北\"之后才补上的，不如一开始就考虑进去。需要注意的是选区状态有时会因为内容变化而失效（比如撤销恢复的节点 id 变了），所以恢复选区时要做好兜底，定位不到就退化到一个合理的默认位置，而不是抛错。",[1091,1387,1389],{"id":1388},"五以画布编辑器为例子的具体实践","五、以画布编辑器为例子的具体实践",[1095,1391,1392],{},"图形画布的好处是它的文档模型通常比富文本干净——大多就是一个图元列表或一棵场景树，每个图元有自己的 id、坐标、尺寸、样式等属性。在这种结构上做撤销相对舒服。",[1095,1394,1395,1396,1399],{},"如果走快照式，配合不可变更新会很顺：每次操作产生一份新的场景树，结构共享让没动的图元复用内存，撤销栈里存的就是这些树的引用。如果走命令式，你需要为每类编辑定义操作和逆操作——新增图元的逆是删除，删除的逆是带着完整属性重新插入（所以删除操作里必须把被删图元的全部数据存下来，否则没法还原），移动的逆是移回原坐标，改样式的逆是改回旧值。这里有个容易踩的坑：",[1287,1397,1398],{},"逆操作所需的信息必须在操作发生时就捕获好","。比如删除一个图元，你不能等撤销的时候才去问\"它原来长什么样\"，那时它已经没了，所以要在执行删除的那一刻把它的快照塞进操作对象。",[1095,1401,1402],{},"画布场景里特别需要处理好拖拽和连续变换的合并。用户拖动、缩放、旋转时会高频产生中间状态，正确做法是在交互开始时（mousedown）记下初始状态，交互过程中只更新视图不入栈，等交互结束时（mouseup）才把\"从初始到最终\"这一整段作为一个撤销单元提交。换句话说，撤销栈应该记录交互的结果，而不是交互的过程。",[1095,1404,1405,1406,1409,1410,1413],{},"另外画布里常有一些不该进撤销历史的状态变化，比如纯粹的视口平移和缩放、图元的临时高亮、辅助线的显隐。判断标准是：这个变化算不算用户对\"文档内容\"的修改？只有内容性的改动才进历史，视图性的、临时性的状态不要污染撤销栈，否则用户会发现自己按了好几次 Ctrl+Z 文档却一动不动，只是画面在缩放。这里更稳的做法不是在入栈口设卡过滤，而是在数据建模阶段就把",[1287,1407,1408],{},"文档状态","（图元的坐标、尺寸、样式、层级，需要持久化、需要协同的内容）和",[1287,1411,1412],{},"会话状态","（视口、选中、悬停高亮、辅助线、拖拽中间态，刷新即丢、别人不需要知道）分成两条独立通道，让撤销系统从源头就只订阅文档状态那一棵树。",[1091,1415,1417],{"id":1416},"六以富文本编辑器为例的具体实践","六、以富文本编辑器为例的具体实践",[1095,1419,1420,1421,1424,1425,1427,1428,1431,1432,1435],{},"富文本是撤销机制里最难啃的一块，难点几乎全部来自 ",[1191,1422,1423],{},"contenteditable","。浏览器原生的 ",[1191,1426,1423],{}," 自带一套撤销栈（也就是 ",[1191,1429,1430],{},"document.execCommand('undo')","），但它基本不可控、跨浏览器行为不一致，而且一旦你用 JavaScript 直接改了 DOM，原生撤销栈就会和修改脱节直接乱掉。\n所以现代富文本编辑器几乎无一例外地选择",[1287,1433,1434],{},"接管撤销","：自己维护一个独立于 DOM 的文档数据模型（document model），所有编辑都先作用在这个模型上，再由框架把模型渲染成 DOM；撤销操作的是模型而不是 DOM。这也是 ProseMirror、Slate、Lexical、Quill 这些主流框架的共同设计——它们都有自己的文档结构和自己的历史模块，并且会主动屏蔽浏览器原生撤销。",[1095,1437,1438],{},"其实完全可以用这些框架自带的历史能力，因为它们已经把无数边界情况处理过了。\n如果确实要自己实现的话还是要注意几个点：",[1095,1440,1441],{},"文本输入的合并，连续打字应该合并成按词撤销，所以你需要在历史模块里实现输入的归并逻辑，并在空格、换行、光标跳转、格式切换等语义边界处断开。富文本里的\"格式操作\"（加粗、改颜色、设标题）和\"内容操作\"（插入删除文字）通常应该是不同的合并组，用户连续打字时不希望中间穿插的一次加粗被一起撤销掉。",[1095,1443,1444,1445,1448,1449,1448,1452,1455,1456,1458],{},"输入法（IME）。中文、日文、韩文用户通过输入法组词时，会触发一连串 ",[1191,1446,1447],{},"compositionstart","、",[1191,1450,1451],{},"compositionupdate",[1191,1453,1454],{},"compositionend"," 事件，组词过程中的中间态绝对不能各自入栈，否则撤销会把用户带回半成品的拼音状态，体验非常糟糕。正确做法是在 composition 期间挂起历史记录，等 ",[1191,1457,1454],{}," 真正确认输入之后，才把这一整段组词作为一个撤销单元提交。",[1095,1460,1461],{},"选区恢复。因为文档结构变化后，原来的选区坐标可能完全失效。主流框架的做法是用一套与具体 DOM 解耦的位置表示（比如基于文档节点路径和偏移量的\"position mapping\"），在文档变换时把旧位置映射到新位置，撤销时再据此还原光标。自己实现的话，至少要保证撤销后光标落在一个合理且稳定的位置，而不是莫名其妙跳到文档开头。",[1091,1463,1465],{"id":1464},"七传统手段下的一些工程细节","七、传统手段下的一些工程细节",[1095,1467,1468],{},"副作用和异步操作。如果某个编辑动作除了改文档还触发了网络请求、文件上传、或者修改了文档之外的外部资源，那么\"撤销文档\"并不能撤销那些副作用。处理原则是尽量让进入历史的操作是纯粹的、对文档模型的同步变更；把网络请求、持久化等副作用从撤销路径里剥离出去，作为模型变更的下游反应来处理，而不是让它们成为撤销单元的一部分。否则用户撤销一次，要去思考\"那个已经上传成功的图片要不要删\"这种很难有正确答案的问题。",[1095,1470,1471],{},"内存管理。无论快照式还是命令式，历史栈都不能无限增长，通常要设一个上限（比如保留最近一两百步），超过就丢弃最老的记录。快照式尤其要注意，哪怕有结构共享，长时间编辑积累的快照也可能可观，可以做\"定期合并旧快照\"或\"只保留近期细粒度、远期粗粒度\"的分层策略。",[1095,1473,1474],{},"任何新的编辑动作都应该清空 redo 栈。用户撤销了几步之后又开始打字，这时之前那条\"重做\"的分支就失去了意义，必须丢弃，否则重做会把文档带到一个和当前编辑逻辑冲突的状态。这是几乎所有编辑器都遵守的标准行为，但自己实现时很容易漏掉。",[1095,1476,1477],{},"撤销 \u002F 重做这种功能极其依赖测试，而且很难靠手测覆盖全。还是要写一份充分的自动化测试，\"随机生成一串操作，全部撤销后断言文档回到初始状态、全部重做后又回到最终状态\"这类性质测试（property-based testing），看能不能揪出那些手工很难想到的逆操作错误。",[1091,1479,1481],{"id":1480},"八协同编辑会彻底改变撤销的含义","八、协同编辑会彻底改变撤销的含义",[1095,1483,1484],{},"单机撤销的世界是干净的：文档只有一条时间线，撤销就是沿着这条线往回走。多人协作彻底打破了这个前提——文档的时间线变成了多个用户的操作交织在一起的一条公共历史，而每个用户心里却各自有一条\"我自己做过什么\"的私有时间线。撤销机制的全部难度，都来自于如何在这条交织的公共历史上，正确地撤掉某个用户的某一步，同时不破坏别人的工作。",[1175,1486,1488],{"id":1487},"_81-撤销的语义是全局还是局部","8.1 撤销的语义是\"全局\"还是\"局部\"",[1095,1490,1491],{},"假设 A 和 B 在同一张画布上，A 移动了一个矩形，紧接着 B 改了一个圆的颜色，现在 A 按下 Ctrl+Z。A 期望发生什么？几乎可以肯定，A 想撤销的是\"自己刚移动的那个矩形\"，而不是\"全局最后一步操作\"——也就是 B 改的颜色。如果你的撤销撤掉了 B 的修改，A 会困惑，B 会愤怒。",[1095,1493,1494,1495,1498],{},"这就是",[1287,1496,1497],{},"局部撤销（local undo \u002F per-user undo）","：让一个\"过时的逆操作\"适配\"已经变了的文档\"就是协同撤销的主要的技术问题。每个用户的撤销只作用于他自己产生的操作。它几乎是所有协作产品的唯一正确选择，Figma、飞书文档、Google Docs 都是这个语义。与之相对的全局撤销（撤销公共历史的栈顶，不管是谁做的）只在极少数单人为主、偶尔多人的场景下才说得通，绝大多数情况下你要实现的是局部撤销。用户要撤销的那一步操作，在它被提交之后，文档上很可能已经叠加了别人（甚至自己）的若干新操作，当时记录的那个\"逆操作\"放到现在的文档上很可能不再成立。",[1175,1500,1502],{"id":1501},"_82-选择协同底座ot-还是-crdt","8.2 选择协同底座：OT 还是 CRDT",[1095,1504,1505],{},"撤销不能脱离协同算法单独设计，因为\"让逆操作适配当前文档\"这件事，本质上就是协同算法在做的事。主流有两条路。",[1095,1507,1508,1509,1512,1513,1516,1517,1520],{},"一条是 ",[1287,1510,1511],{},"OT（Operational Transformation，操作变换）","。它把每次编辑表示成一个操作（op），核心是一个 ",[1191,1514,1515],{},"transform"," 函数：给定两个并发的操作 a 和 b，它能算出\"在已经应用了 b 的文档上，a 应该变成什么样\"。Google Docs 早期就是 OT。OT 的撤销依赖一个叫 ",[1287,1518,1519],{},"transform-against-the-history"," 的思路——把要撤销的逆操作，依次对它之后发生的每一个操作做 transform，变换到当前状态再应用。OT 概念直观但 transform 函数极难写对，尤其是树形结构上的并发场景，是出了名的容易出 bug。",[1095,1522,1523,1524,1527,1528,1531],{},"另一条是 ",[1287,1525,1526],{},"CRDT（Conflict-free Replicated Data Type，无冲突复制数据类型）","。它的思路是把数据结构设计成\"无论操作以什么顺序到达、合并结果都一致\"，从而免去中央服务器做 transform 的负担。Yjs、Automerge 是这条路的代表。CRDT 对应用开发者最大的好处是：",[1287,1529,1530],{},"它通常自带了协同感知的撤销管理器","，你不用自己去实现 transform-against-history 那套东西。",[1095,1533,1534,1535,1538],{},"绝大多数情况都可以直接用 Yjs 这类成熟 CRDT 库，它的 ",[1191,1536,1537],{},"UndoManager"," 已经把局部撤销、操作变换、选区恢复这些都处理好了。下面先给出基于 Yjs 的完整落地方案（推荐路线），再讲清楚如果你必须自研 OT，撤销算法的原理长什么样。",[1175,1540,1542],{"id":1541},"_83-基于-yjs-的协同撤销","8.3 基于 Yjs 的协同撤销",[1095,1544,1545],{},"把画布的文档状态建模成一个共享数据结构，所有客户端各持一份副本，本地编辑先改自己的副本、立刻渲染（保证手感），同时把改动作为增量广播出去；收到别人的增量就合并进自己的副本。撤销由每个客户端本地的 UndoManager 负责，它只追踪\"本客户端产生的\"变更。",[1183,1547,1552],{"className":1548,"code":1550,"language":1551},[1549],"language-text","本地编辑 → 改 Yjs 文档 → 1) 本地渲染  2) 编码成 update 广播\n收到远端 update → 合并进 Yjs 文档 → 重新渲染\nCtrl+Z → UndoManager.undo() → 只回退本客户端的变更 → 触发渲染 + 广播\n","text",[1191,1553,1550],{"__ignoreMap":1189},[1555,1556,1557],"h3",{"id":1557},"文档建模",[1095,1559,1560,1561,1564,1565,1567,1568,1571,1572,1575],{},"把每个图元建成一个 Yjs 的 ",[1191,1562,1563],{},"Y.Map","，整张画布是一个 ",[1191,1566,1563],{},"（以图元 id 为 key）或 ",[1191,1569,1570],{},"Y.Array","。关键点是：",[1287,1573,1574],{},"所有会被撤销、会被协同的属性都必须存在 Yjs 类型里","，而视口、高亮、选区这些会话状态绝对不要放进去（对应第五节强调的双通道隔离——会话状态不进文档、不进历史、也不进协同）。",[1183,1577,1582],{"className":1578,"code":1580,"language":1581,"meta":1189},[1579],"language-js","import * as Y from \"yjs\";\n\nconst ydoc = new Y.Doc();\n\u002F\u002F 整张画布：key 是图元 id，value 是描述图元的 Y.Map\nconst yShapes = ydoc.getMap(\"shapes\");\n\nfunction addShape(shape) {\n    \u002F\u002F 用事务包裹，保证这是一个原子的撤销单元\n    ydoc.transact(() => {\n        const yShape = new Y.Map();\n        yShape.set(\"x\", shape.x);\n        yShape.set(\"y\", shape.y);\n        yShape.set(\"w\", shape.w);\n        yShape.set(\"h\", shape.h);\n        yShape.set(\"fill\", shape.fill);\n        yShapes.set(shape.id, yShape);\n    }, \u002F* origin *\u002F localOrigin);\n}\n","js",[1191,1583,1580],{"__ignoreMap":1189},[1095,1585,1586,1587,1590,1591,1594],{},"其中 ",[1191,1588,1589],{},"transact"," 的第二个参数 ",[1191,1592,1593],{},"origin","标记了\"这次变更是谁、从哪个来源发起的\"。这个参数是协同撤销的开关",[1555,1596,1598],{"id":1597},"配置只撤销本地操作的-undomanager","配置只撤销本地操作的 UndoManager",[1095,1600,1601,1602,1604,1605,1608],{},"Yjs 的 ",[1191,1603,1537],{}," 默认会捕获它所观察的共享类型上的所有变更，但你可以通过 ",[1191,1606,1607],{},"trackedOrigins"," 限定它只追踪特定来源的变更。把本地操作统一打上同一个 origin，再让 UndoManager 只追踪这个 origin，局部撤销就实现了——远端来的变更（origin 不同）不会进入本地的撤销栈。",[1183,1610,1613],{"className":1611,"code":1612,"language":1581,"meta":1189},[1579],"const localOrigin = { user: myUserId }; \u002F\u002F 本地变更统一用这个 origin\n\nconst undoManager = new Y.UndoManager(yShapes, {\n    trackedOrigins: new Set([localOrigin]), \u002F\u002F 只追踪本地来源\n    captureTimeout: 500, \u002F\u002F 500ms 内的连续变更合并为一个撤销单元\n});\n\n\u002F\u002F 撤销 \u002F 重做\nfunction undo() {\n    undoManager.undo();\n}\nfunction redo() {\n    undoManager.redo();\n}\n",[1191,1614,1612],{"__ignoreMap":1189},[1095,1616,1617,1618,1621,1622,1625],{},"这里 ",[1191,1619,1620],{},"captureTimeout"," 就是前面讲过的\"合并 \u002F coalescing\"机制——连续拖动、连续微调在 500ms 窗口内会被并成一步，避免用户按到崩溃。需要强制断点（比如交互结束、切换了操作类型）时，调用 ",[1191,1623,1624],{},"undoManager.stopCapturing()"," 主动结束当前合并窗口，下一次变更就成为新的撤销单元。",[1555,1627,1629],{"id":1628},"连续交互仍然遵循过程不入栈结果才提交","连续交互仍然遵循\"过程不入栈、结果才提交\"",[1095,1631,1632],{},"协同环境下，拖拽过程同样不应该每一帧都写进 Yjs（既污染历史，又会把上百个中间态广播出去造成网络风暴）。做法是拖动过程中只改本地会话状态驱动渲染，松手时才用一个事务把最终结果写进 Yjs：",[1183,1634,1637],{"className":1635,"code":1636,"language":1581,"meta":1189},[1579],"let dragStart = null;\n\nfunction onPointerDown(shapeId, pt) {\n    const yShape = yShapes.get(shapeId);\n    dragStart = { x: yShape.get(\"x\"), y: yShape.get(\"y\"), px: pt.x, py: pt.y };\n}\n\nfunction onPointerMove(shapeId, pt) {\n    \u002F\u002F 只更新会话状态 → 实时渲染，不碰 Yjs，不入栈，不广播\n    sessionStore.setGhostPosition(shapeId, {\n        x: dragStart.x + (pt.x - dragStart.px),\n        y: dragStart.y + (pt.y - dragStart.py),\n    });\n}\n\nfunction onPointerUp(shapeId) {\n    const ghost = sessionStore.getGhostPosition(shapeId);\n    ydoc.transact(() => {\n        const yShape = yShapes.get(shapeId);\n        yShape.set(\"x\", ghost.x);\n        yShape.set(\"y\", ghost.y);\n    }, localOrigin); \u002F\u002F 一次事务 = 一个撤销单元\n    undoManager.stopCapturing(); \u002F\u002F 让这次拖拽成为独立的一步\n    sessionStore.clearGhost(shapeId);\n}\n",[1191,1638,1636],{"__ignoreMap":1189},[1555,1640,1641],{"id":1641},"网络同步",[1095,1643,1644],{},"同步层负责把本地的增量编码后发出去、把收到的增量合并进来。这里只发文档增量，会话状态（光标、选区、视口）走另一条轻量的 awareness 通道，两者分开。",[1183,1646,1649],{"className":1647,"code":1648,"language":1581,"meta":1189},[1579],"\u002F\u002F 本地文档变更 → 广播\nydoc.on(\"update\", (update, origin) => {\n    if (origin === \"remote\") return; \u002F\u002F 别把远端来的又发回去\n    network.broadcast(encodeUpdate(update));\n});\n\n\u002F\u002F 收到远端增量 → 合并（注意origin 标成 remote，不会进本地撤销栈）\nnetwork.onMessage((update) => {\n    Y.applyUpdate(ydoc, decodeUpdate(update), \"remote\");\n});\n\n\u002F\u002F 任何文档变化都触发重渲染（本地、远端、撤销都会走到这）\nyShapes.observeDeep(() => renderCanvas(yShapes));\n",[1191,1650,1648],{"__ignoreMap":1189},[1095,1652,1653,1654,1657],{},"这套配置下的局部撤销中，远端变更 origin 是 ",[1191,1655,1656],{},"'remote'","，不被 UndoManager 追踪；本地变更才进撤销栈。而当 A 撤销自己的某一步时，即便这一步之后 B 已经改过别的图元，Yjs 的 CRDT 模型会保证 A 的逆操作正确合并进当前文档状态，B 的改动不受影响——这部分\"逆操作适配当前文档\"的复杂逻辑全部由库内部处理了。",[1555,1659,1660],{"id":1660},"选区与协同感知",[1095,1662,1663,1664,1667,1668,1671],{},"撤销时恢复选区，在 Yjs 里靠的是相对位置（relative position）——一种和具体索引解耦、能在文档变化后映射回正确位置的坐标。UndoManager 支持在每个撤销单元上挂载额外的元信息，可以借 ",[1191,1665,1666],{},"stack-item-added"," \u002F ",[1191,1669,1670],{},"stack-item-popped"," 事件把操作前的选区存进去、撤销时取出来还原：",[1183,1673,1676],{"className":1674,"code":1675,"language":1581,"meta":1189},[1579],"undoManager.on(\"stack-item-added\", (event) => {\n    event.stackItem.meta.set(\"selection\", sessionStore.getSelection());\n});\nundoManager.on(\"stack-item-popped\", (event) => {\n    const sel = event.stackItem.meta.get(\"selection\");\n    if (sel) sessionStore.restoreSelection(sel); \u002F\u002F 定位不到时内部要兜底\n});\n",[1191,1677,1675],{"__ignoreMap":1189},[1095,1679,1680,1681,1684],{},"别人的光标、选区、视口则通过 Yjs 的 ",[1191,1682,1683],{},"awareness"," 协议广播，它是临时状态、不进文档也不进历史，正好对应会话状态的归属。",[1175,1686,1688],{"id":1687},"_84-ot-路线的撤销原理","8.4 OT 路线的撤销原理",[1095,1690,1691],{},"有些团队因为历史包袱或特殊约束无法引入 CRDT 库，只能在自研 OT 上做撤销。这里讲清楚算法骨架，但要提醒：transform 的正确性是这条路最大的风险，务必配性质测试死守。",[1095,1693,1694,1695,1698,1699,1702],{},"核心数据有两套。一是服务端维护的",[1287,1696,1697],{},"全局操作历史","（所有人已确认的操作的有序列表）。二是每个客户端本地的",[1287,1700,1701],{},"两个栈","：undo 栈和 redo 栈，里面存的是\"本用户自己操作的逆操作\"。",[1095,1704,1705,1706,1709,1710,1713,1714,1716,1717,1719,1720,1722,1723,1726,1727,1729,1730,1732,1733,1736],{},"撤销的算法是这样的。当用户要撤销自己的操作 ",[1191,1707,1708],{},"op","（它的逆是 ",[1191,1711,1712],{},"inv","）时，",[1191,1715,1712],{}," 是基于\"",[1191,1718,1708],{}," 刚提交时的文档\"算出来的，但现在文档上已经叠加了 ",[1191,1721,1708],{}," 之后的一串操作 ",[1191,1724,1725],{},"[c1, c2, ..., cn]","（可能是别人的，也可能是自己后来做的）。直接应用 ",[1191,1728,1712],{}," 会出错，所以要把 ",[1191,1731,1712],{}," 依次对这些后续操作做 transform，得到一个适配当前文档的 ",[1191,1734,1735],{},"inv'","，再应用并广播它：",[1183,1738,1741],{"className":1739,"code":1740,"language":1551},[1549],"inv' = inv\nfor c in [c1, c2, ..., cn]:        # op 之后发生的每一个操作\n    inv' = transform(inv', c)      # 把逆操作\"推进\"到当前状态\napply(inv')                        # 应用变换后的逆操作\nbroadcast(inv')                    # 当作一个新操作发给别人\npush_to_redo(invert(inv'))         # 它的逆进 redo 栈，以便重做\n",[1191,1742,1740],{"__ignoreMap":1189},[1095,1744,1745,1748],{},[1191,1746,1747],{},"transform(a, b)"," 的含义是\"在已经发生了 b 的前提下，a 该变成什么样才能保持原本的意图\"。比如 a 想删除第 5 个字符，而 b 在第 2 个位置插了 3 个字符，那么 transform 后 a 就该去删第 8 个字符。图形画布上相对简单些（移动、改属性大多互不干扰，按 id 定位即可），但涉及结构变化（删除、分组、层级调整）的并发就会变得棘手，每一对操作类型的组合都要写出正确的 transform 规则，这是工作量和 bug 的主要来源。",[1095,1750,1751,1752,1755],{},"重做是对称的：从 redo 栈取出操作，同样对它之后发生的操作做 transform，再应用和广播。同时要守住单机时代就有的那条规则——",[1287,1753,1754],{},"用户产生任何新的正向编辑时，清空 redo 栈","，因为重做分支已经失去意义。",[1175,1757,1759],{"id":1758},"_85-协作场景特有的几个坑","8.5 协作场景特有的几个坑",[1095,1761,1762,1763,1766],{},"第一，",[1287,1764,1765],{},"撤销本身也是一个要广播的操作","。本地撤销改了文档，这个改动必须像普通编辑一样发给其他人，否则各客户端会发散。所以撤销不是\"本地回退\"，而是\"产生一个让文档回退的新操作并同步出去\"。",[1095,1768,1769,1770,1773],{},"第二，",[1287,1771,1772],{},"注意撤销别人依赖你的内容时的连锁反应","。比如 A 画了个矩形，B 把一条连线连到了这个矩形上，然后 A 撤销了\"创建矩形\"。这时 B 的连线指向了一个不存在的对象。CRDT 库通常会把这种悬空引用处理成一个合理的最终态（连线变成悬空或被清理），但如果是自研，你必须明确定义这种引用完整性该怎么收场，否则会出脏数据。",[1095,1775,1776,1777,1780],{},"第三，",[1287,1778,1779],{},"会话状态绝不能进协同文档","。这点前面反复强调过，在多人场景下更要命——如果你不小心把\"选中状态\"写进了共享文档，那么 A 选中一个图元，B 屏幕上那个图元也会被选中，撤销栈里还会塞满毫无意义的\"选区变更\"。选区、高亮、视口、拖拽中间态一律走 awareness 这类临时通道，和文档、历史彻底隔离。",[1095,1782,1783,1784,1787],{},"第四，",[1287,1785,1786],{},"captureTimeout \u002F 合并窗口在协同下要更克制","。单机时连续打字合并成一步没问题，但协同时如果合并窗口太长，可能把跨越了别人操作的两段本地变更并成一个撤销单元，撤销时行为会变怪。让合并只在没有被远端操作打断的连续本地动作之间生效，是比较稳妥的策略，Yjs 的 UndoManager 内部已经考虑了这类边界。",[1091,1789,1790],{"id":1790},"总结",[1095,1792,1793],{},"总之做这个的话先有一个单一、清晰、可序列化的文档模型；在快照式和命令式之间根据规模和协同需求做出取舍；用事务和合并把撤销颗粒度对齐到用户的意图而不是数据结构；连同选区一起回退以保证上下文不丢；针对画布处理好连续交互的合并、把会话状态与文档状态分成两条通道，针对富文本处理好 IME 和原生 contenteditable 的接管；\n如果有协同就从一开始按局部撤销和操作变换来设计，多数还是用 Yjs 这类成熟框架",[1095,1795,1796],{},"反正具体情况具体来看。",{"title":1189,"searchDepth":1798,"depth":1798,"links":1799},4,[1800,1802,1803,1804,1805,1806,1814,1815],{"id":1177,"depth":1801,"text":1178},2,{"id":1236,"depth":1801,"text":1237},{"id":1335,"depth":1801,"text":1336},{"id":1487,"depth":1801,"text":1488},{"id":1501,"depth":1801,"text":1502},{"id":1541,"depth":1801,"text":1542,"children":1807},[1808,1810,1811,1812,1813],{"id":1557,"depth":1809,"text":1557},3,{"id":1597,"depth":1809,"text":1598},{"id":1628,"depth":1809,"text":1629},{"id":1641,"depth":1809,"text":1641},{"id":1660,"depth":1809,"text":1660},{"id":1687,"depth":1801,"text":1688},{"id":1758,"depth":1801,"text":1759},"md",true,{"uuid":1819,"slots":1820},"1bc528a1-e968-1211-b2a4-1303641a6b9a",{},26,{"title":1085,"description":1097},"posts\u002F2025\u002F2025-11-23-Undo-Redo-机制具体实现",[135,145],"cGo71xjla9xBEowFaGx234w_BW-RZgJdR4r3KRtYCwQ",1790443287697]