Elementor 编辑器历史记录组件解析:document/history 命令体系与撤销/重做实现
Elementor 编辑器历史记录组件解析:document/history 命令体系与撤销/重做实现
本指南以 Elementor 开源仓库中 docs/assets/dev/js/editor/document/history/readme.md 为骨架,系统讲解编辑器历史记录组件 $e.components.get('document/history') 的设计:包括历史条目(Items)、子条目(Sub-Items)与事务(Transactions)三类实体,以及 Do / Undo / Redo / Undo-All 公开命令和 Add-Transaction / Start-Log / End-Log 等内部命令的完整参数契约。同时结合仓库内真实源码(组件、命令、HistoryManager、历史面板)逐层拆解底层实现,帮助你理解 Elementor 命令式架构中"命令与历史解耦"的设计,并掌握在自己的编辑器命令中接入历史记录的具体方法。
一、组件概览:它管理了什么
document/history 是 Elementor 编辑器 $e 命令系统中的一个核心组件,通过 $e.components.get('document/history') 获取。根据 组件定义 中的 getNamespace(),其命名空间即为 document/history,所有命令都以 $e.run('document/history/...') 或 $e.internal('document/history/...') 的方式触发。
组件职责官方描述如下:
提供一种管理编辑器历史记录的方式——即创建和删除历史条目(里程碑)、在它们之间时间旅行(撤销与重做),以及启动会被保存为历史条目的事务。
换句话说,它不是一个"记录数据的存储层",而是一个协调层:把编辑器中分散的各种数据变更操作(创建元素、修改设置、粘贴、移动等)统一编排为可回放的历史条目。
从 模块入口 与 Document 模型 可以看出,每个文档(Document)实例都会持有一个 HistoryManager:
constructor( config ) {
this.config = config;
this.id = config.id;
this.history = new HistoryManager( this );
this.revisions = new RevisionsManager( this );
}
因此历史记录是按文档隔离的,elementor.documents.getCurrent().history 即当前文档的历史管理器实例,这也解释了为什么所有历史命令都要通过 elementor.documents.getCurrent() 来定位目标。
二、三类核心实体:Items / Sub-Items / Transactions
原文档指出,历史组件围绕三类实体工作,这是理解整个组件行为模型的基石:
| 实体 | 行为特征 |
|---|---|
| Items(主条目) | 历史面板中直接可见的条目,每个主条目可以包含多个子条目 |
| Sub-Items(子条目) | 子条目的变更不会直接体现在主条目上,但会参与撤销/重做时对文档的实际影响 |
| Transactions(事务) | 事务只保存其中的第一个条目和最后一个条目,中间过程被合并 |
这一设计在源码中体现得非常明确:
- HistoryManager.addItem() 中,每次调用
startItem会创建或复用主条目,并把传入的itemData追加到主条目的items集合(一个 Backbone.Collection)中,实现"主条目 + 子条目"的树形结构; - ItemModel 的默认字段包含
id、type、status、title、subTitle、action、history,并在initialize()中创建items子集合; - 事务的合并逻辑位于 component.js 的 mergeTransactions(),它遍历事务列表,对同一容器(
container.id)的多次变更只保留最后一次的new值——这正是"只保存第一个和最后一个条目"的实现。
组件还维护了一个 transactions 数组作为事务暂存区(__construct 中初始化),并暴露 isTransactionStarted() 用于判断当前是否处于事务中,供各内部命令读取。
三、内置历史类型(historyTypes)
历史条目标题使用一套内置类型映射,原文档中给出的完整定义如下(注意 HistoryManager 中的 translations 表 是这套类型的运行时落地,并通过 getActionLabel() 把类型翻译成可读文本):
// Represent history item titles, used in some of the History component commands.
const historyTypes = {
"add": "Added",
"change": "Edited",
"disable": "Disabled",
"duplicate": "Duplicate",
"enable": "Enabled",
"move": "Moved",
"paste": "Pasted",
"paste_style": "Style Pasted",
"remove": "Removed",
"reset_style": "Style Reset",
"reset_settings": "Settings Reset"
}
在实际源码中,类型集合略有扩展,例如 HistoryManager.translations 还额外包含 import(Imported)和 selected(Selected)两种类型,并且所有文案均通过 WordPress 的 __( text, 'elementor' ) 进行国际化。这意味着:
type字段是历史条目的"动作分类",渲染时由getActionLabel()转为用户可读的动作名;- 自定义历史条目若使用表中未覆盖的
type,getActionLabel()会直接返回原始type字符串作为兜底。
四、命令总览:公开命令与内部命令
组件将命令分为两类:公开命令(可通过 $e.run 触发)和内部命令(只能通过 $e.internal 触发,供框架内部编排使用)。这一区分在 component.js 中通过 defaultCommands() 与 defaultCommandsInternal() 两个方法分别注册:
defaultCommands() {
return this.importCommands( commands );
}
defaultCommandsInternal() {
return this.importCommands( commandsInternal );
}
公开命令
| 命令 | 调用方式 | 说明 |
|---|---|---|
| Do | $e.run('document/history/do') |
执行/跳转到指定历史条目 |
| Undo | $e.run('document/history/undo') |
撤销一步 |
| Undo-All | $e.run('document/history/undo-all') |
撤销全部历史条目 |
| Redo | $e.run('document/history/redo') |
重做一步 |
内部命令
| 命令 | 调用方式 | 说明 |
|---|---|---|
| Add-Transaction | $e.internal('document/history/add-transaction') |
添加事务条目 |
| Clear-Transaction | $e.internal('document/history/clear-transaction') |
清空事务列表 |
| Delete-Log | $e.internal('document/history/delete-log') |
删除日志(条目及其全部子条目) |
| End-Log | $e.internal('document/history/end-log') |
结束日志记录 |
| End-Transaction | $e.internal('document/history/end-transaction') |
结束事务,将其合并为一个历史条目 |
| Log-Sub-Item | $e.internal('document/history/log-sub-item') |
记录子条目 |
| Start-Log | $e.internal('document/history/start-log') |
开始一条日志记录 |
命令源码全部位于 assets/dev/js/editor/document/history/commands/,其中公开命令放在根目录(do.js、undo.js、redo.js、undo-all.js),内部命令放在 internal/ 子目录,base/command-history-internal-base.js 则提供了内部命令的公共基类。
五、公开命令详解
5.1 Do — $e.run('document/history/do')
| 属性 | 类型 | 要求 | 说明 |
|---|---|---|---|
index |
{Number} |
可选 | 要跳转的历史条目索引 |
源码实现(do.js)非常直接——把 index 转发给当前文档历史管理器的 doItem():
export class Do extends $e.modules.CommandBase {
apply( args ) {
const { index } = args;
return elementor.documents.getCurrent().history.doItem( index );
}
}
注意:index 缺省时 doItem( undefined ) 不会执行任何条目(items.at( undefined ) 无结果),因此该参数在实际使用中应视为必须提供。历史面板(panel-tab.js)在用户点击某条历史记录时,正是通过 $e.run( 'panel/history/actions/do', { index } ) 触发此命令。
doItem() 的行为(manager.js#L159-L214)值得展开:
- 先通过
setActive( false )关闭历史记录追踪,避免恢复动作本身又被记入历史; - 根据目标条目
status决定执行撤销(undoItem)还是重做(redoItem); - 恢复完成后重新
setActive( true ); - 更新面板中"当前条目"高亮,并在元素移出视口时自动滚动定位;
- 最后通过
$e.internal( 'document/save/set-is-modified', ... )同步文档修改状态(比较条目 id 与document.editor.lastSaveHistoryId)。
5.2 Undo — $e.run('document/history/undo')
无参数,返回 {void}。源码(undo.js):
export class Undo extends $e.modules.CommandBase {
apply() {
const historyItem = elementor.documents.getCurrent().history.navigate();
// The history item that was just undone.
return { originHistoryItemId: historyItem?.get( 'id' ) ?? null };
}
}
实现上调用 HistoryManager.navigate()(不带参数,即撤销方向),实际返回值是一个包含 originHistoryItemId 的对象(当没有可撤销条目时为 null),用于通知监听方刚被撤销的是哪一条,方便做后续联动。
5.3 Undo-All — $e.run('document/history/undo-all')
无参数,返回 {void}。源码(undo-all.js):
export class UndoAll extends $e.modules.CommandBase {
apply( args ) {
const { document } = args;
document.history.doItem( document.history.getItems().length - 1 );
}
}
实现要点:getItems() 返回历史条目的 Backbone.Collection(manager.js#L75-L77),集合中始终存在一个 editing_started 的起始标记条目(在 addItem() 首次创建时自动插入,见 manager.js#L116-L124),因此 length - 1 即最后一个真实历史条目——一次 doItem 会级联恢复其索引之前的所有未应用条目。注意该命令期望从 args.document 中读取文档对象。
5.4 Redo — $e.run('document/history/redo')
无参数,返回 {void}。源码(redo.js)与 Undo 对称,只是 navigate( true ) 传入 isRedo = true。
navigate() 的核心逻辑(manager.js#L49-L65):
- 先找到第一个
status === 'not_applied'的条目作为"当前游标"; - 撤销方向取
requiredIndex = currentItemIndex + 1,重做方向取requiredIndex = currentItemIndex - 1; - 越界(
requiredIndex < 0 || requiredIndex >= length)或撤销时不存在未应用条目则直接返回; - 否则调用
doItem( requiredIndex )完成实际恢复。
这解释了撤销/重做"游标"的本质:历史条目按时间顺序排列,not_applied 表示"该条目及其之后的操作尚未应用",applied 表示"已应用/已撤销过"。
六、内部命令详解
内部命令统一继承 CommandHistoryInternalBase(command-history-internal-base.js),它在 initialize() 中注入当前文档的 HistoryManager:
this.history = elementor.documents.getCurrent().history;
6.1 Start-Log — $e.internal('document/history/start-log')
开始一条日志记录。返回 {Number}(日志 id)。
| 属性 | 类型 | 要求 | 说明 |
|---|---|---|---|
container |
{Container} |
必填 | 容器日志 |
containers |
{Container[]} |
必填 | 容器日志数组 |
type |
{String} |
必填 | 类型 |
title |
{String} |
必填 | 标题 |
subTitle |
{String} |
可选 | 副标题 |
restore |
{function()} |
可选 | 恢复函数 |
源码(start-log.js)展示了主条目与子条目的分流机制:
initialize( args ) {
super.initialize( args );
if ( this.history.isItemStarted() || args.id ) {
this.isSubItem = true;
return;
}
this.args = this.component.normalizeLogTitle( args );
}
validateArgs( args ) {
if ( ! this.isSubItem ) {
this.requireArgumentType( 'type', 'string', args );
this.requireArgumentType( 'title', 'string', args );
}
}
apply( args ) {
if ( this.isSubItem ) {
$e.internal( 'document/history/log-sub-item', args );
return null;
}
return this.history.startItem( args );
}
- 如果当前已有条目在进行中(
isItemStarted())或显式传入了id,则本次 start 自动降级为"子条目",内部转调log-sub-item; - 只有真正开启一条新主条目时才强制校验
type与title; - 未显式提供
title时,由 normalizeLogTitle() 自动生成:单个容器使用容器的label,多个容器统一使用__( 'Elements', 'elementor' )。
6.2 End-Log — $e.internal('document/history/end-log')
结束日志记录。无参数(如需指定结束哪条,可传 { id })。源码(end-log.js):
apply( args ) {
if ( args.id ) {
this.history.endItem( args.id );
}
}
对应 manager.js 的 endItem():仅当传入 id 与 currentItemID 一致时才把 currentItemID 置空,即"关闭当前打开的日志"。这保证了日志是成对且串行的——必须先 start 后 end,且 start/end 不能交叉嵌套。
6.3 Add-Transaction — $e.internal('document/history/add-transaction')
添加事务条目(事务模式下的"排队"动作)。返回 {void}。
| 属性 | 类型 | 要求 | 说明 |
|---|---|---|---|
container |
{Container} |
必填 | 容器日志 |
containers |
{Container[]} |
必填 | 容器日志数组 |
type |
{String} |
必填 | 类型 |
title |
{String} |
可选 | 标题 |
subTitle |
{String} |
可选 | 副标题 |
restore |
{function()} |
可选 | 恢复函数 |
源码(add-transaction.js)中有一个值得注意的优化:
initialize( args ) {
super.initialize( args );
/**
* Debounce always send 'add-transaction' with title & subTitle, when the transaction
* already started, there is no need to save those args they are useless.
*/
if ( this.component.isTransactionStarted() ) {
delete args.title;
delete args.subTitle;
}
}
validateArgs( args ) {
this.requireContainer();
this.requireArgumentType( 'type', 'string', args );
}
apply( args ) {
const currentId = this.history.getCurrentId();
if ( currentId ) {
// If log already started chain his historyId.
args.id = currentId;
}
args = this.component.normalizeLogTitle( args );
this.component.transactions.push( args );
}
- 事务一旦开始,后续 add 动作不再重复保存
title/subTitle(最终标题取自第一条); - 若当前已有日志在记录(
currentId存在),会给事务条目挂上该 id,使事务与已存在的日志条目建立关联; - 事务条目统一压入
component.transactions数组暂存。
6.4 End-Transaction — $e.internal('document/history/end-transaction')
结束事务:把暂存的事务列表合并为一条新的历史条目。无参数,返回 {void}。源码(end-transaction.js)是整个事务机制的收口:
apply() {
if ( ! this.component.isTransactionStarted() ) {
return;
}
const firstItem = this.component.transactions[ 0 ],
{ type } = firstItem,
transactions = this.component.mergeTransactions( this.component.transactions );
let { title = '', subTitle = '' } = firstItem;
// 'elements' title for multiple containers.
if ( transactions.length > 1 ) {
title = __( 'Elements', 'elementor' );
subTitle = '';
}
const history = { title, subTitle, type };
// If firstItem have id already it means that log already started for that transaction.
if ( firstItem.id ) {
history.id = firstItem.id;
}
const historyId = $e.internal( 'document/history/start-log', history );
Object.values( transactions ).forEach( ( item ) => {
const itemArgs = item;
// If log already started chain his historyId.
if ( firstItem.id ) {
itemArgs.id = firstItem.id;
}
$e.internal( 'document/history/log-sub-item', itemArgs );
} );
$e.internal( 'document/history/end-log', { id: historyId } );
// Clear transactions before leave.
$e.internal( 'document/history/clear-transaction' );
}
流程可归纳为五步:
- 未开启事务则直接返回(幂等保护);
- 用
mergeTransactions()把多个容器/多次变更合并(见前文,同一容器只保留最后一次new值); - 从第一条事务取
type,标题取自第一条;涉及多个容器时统一标题为"Elements"; - 先
start-log开启主条目,再把合并后的每个事务条目作为log-sub-item追加,最后end-log收尾; - 无论成功与否,最后都会
clear-transaction清空暂存区。
6.5 Clear-Transaction — $e.internal('document/history/clear-transaction')
清空事务列表。无参数。源码(clear-transaction.js)一行即可完成:
apply() {
this.component.transactions = [];
}
通常在事务被取消或结束时调用,防止脏数据残留影响下一次事务。
6.6 Log-Sub-Item — $e.internal('document/history/log-sub-item')
记录子条目。返回 {void}。每个历史条目都可以拥有子条目(它们不会显示在历史面板中,但会实际影响撤销/重做时文档的变更)。
| 属性 | 类型 | 要求 | 说明 |
|---|---|---|---|
id |
{Number} |
可选 | 父历史条目的 id,默认 elementor.documents.currentDocument.history.getCurrentId() |
container |
{Container} |
可选 | 容器日志 |
containers |
{Container[]} |
可选 | 容器日志数组 |
type |
{String} |
可选 | 类型 |
title |
{String} |
可选 | 标题 |
subTitle |
{String} |
可选 | 副标题 |
restore |
{function()} |
可选 | 恢复函数 |
源码(log-sub-item.js):
apply( args ) {
if ( ! this.history.getActive() ) {
return;
}
const id = args.id || this.history.getCurrentId();
args = this.component.normalizeLogTitle( args );
const items = this.history.getItems(),
item = items.findWhere( { id } );
if ( ! item ) {
throw new Error( 'History item not found.' );
}
if ( args.id ) {
delete args.id;
}
item.get( 'items' ).unshift( args );
}
几个关键行为:
- 历史被禁用(
getActive()为 false)时静默跳过; - 目标父条目找不到时抛出
'History item not found.'错误,属于硬失败; - 子条目通过
unshift插入父条目items集合头部(最新变更在前); args.id仅用于定位父条目,会被删除,不会作为子条目数据保存。
6.7 Delete-Log — $e.internal('document/history/delete-log')
删除历史条目及其全部子条目。返回 {void}。
| 属性 | 类型 | 要求 | 说明 |
|---|---|---|---|
id |
{Number} |
必填 | 要删除的历史日志 id |
源码(delete-log.js)转发给 history.deleteItem( id )。对应的 deleteItem() 在集合中按 id 找到模型后 items.remove( item ),同时把 currentItemID 置空。该命令在命令执行失败回滚时用于清除半成品日志(见下节)。
七、命令如何接入历史:CommandHistoryBase 生命周期
对于普通编辑器命令而言,它们并不直接调用上述内部命令,而是继承 CommandHistoryBase(command-history-base.js),由框架自动完成 start-log / end-log / delete-log 的编排。这是理解"命令与历史解耦"的关键:
initialize( args = {} ) {
const { options = {} } = args,
{ useHistory = true } = options;
if ( useHistory ) {
this.history = this.getHistory( args ); // 子类返回历史元数据
this.historyId = false;
}
}
onBeforeRun( args ) {
super.onBeforeRun( args );
if ( this.history && this.isHistoryActive() ) {
this.historyId = $e.internal( 'document/history/start-log', this.history );
}
}
onAfterRun( args, result ) {
super.onAfterRun( args, result );
if ( this.history && this.isHistoryActive() ) {
$e.internal( 'document/history/end-log', { id: this.historyId } );
}
}
onCatchApply( e ) {
// Rollback history on failure.
if ( e instanceof $e.modules.HookBreak && this.historyId ) {
$e.internal( 'document/history/delete-log', { id: this.historyId } );
}
super.onCatchApply( e );
}
生命周期如下:
- 初始化:子类重写
getHistory()返回历史元数据对象(含type、title、subTitle、restore等,见 create.js、delete.js、duplicate.js、move.js 等元素的写命令);若命令不产生历史,可返回false或通过options.useHistory = false关闭; - 运行前:
onBeforeRun自动start-log,得到historyId; - 运行后:
onAfterRun自动end-log闭合日志; - 失败回滚:若执行过程中抛出了
HookBreak中断,onCatchApply自动delete-log删除半成品日志,保证失败命令不会污染历史。
结合 6.1 的分流逻辑,可以推出一个完整的行为链:嵌套调用 start-log 时会自动变成 log-sub-item,因此一个复杂操作可以由若干子命令组成,却只在历史面板显示为一条主条目——这正是事务与子条目机制组合使用的典型场景。
八、历史面板与撤销/重做的前端呈现
历史记录最终通过编辑器右侧的"历史"面板呈现给用户,相关视图位于 modules/history/assets/js/history/:
- panel-tab.js 定义面板容器
elementor-panel-history,用 Marionette.CompositeView 渲染历史列表,并高亮当前条目(elementor-history-item-current); - item-view.js 渲染单个条目;
- empty.js 在无历史时显示空状态;
- 点击某条历史时,面板通过
$e.run( 'panel/history/actions/do', { index } )触发跳转(panel-tab.js#L53-L62),最终落到 5.1 的document/history/do命令; doItem()执行后通过 updatePanelPageCurrentItem() 通知面板刷新"当前条目"高亮,形成闭环。
九、参数契约速查与实战建议
9.1 参数速查表
| 命令 | 关键参数 | 必填/可选 |
|---|---|---|
document/history/do |
index: Number |
可选(实际建议必传) |
document/history/undo |
无 | — |
document/history/undo-all |
document: Document |
必填(来自 args) |
document/history/redo |
无 | — |
document/history/start-log |
container / containers、type、title |
必填;subTitle、restore 可选 |
document/history/end-log |
id(不传则以当前 id 为准) |
可选 |
document/history/add-transaction |
container / containers、type |
必填;其余可选 |
document/history/end-transaction |
无 | — |
document/history/clear-transaction |
无 | — |
document/history/log-sub-item |
id、container(s)、type、title、subTitle、restore |
全部可选(默认挂到当前条目) |
document/history/delete-log |
id: Number |
必填 |
9.2 实战建议
- 为自定义写命令开启历史:继承
CommandHistoryBase并实现getHistory(),返回{ type, title, subTitle, restore },框架会自动 start/end 日志,无需手动调用内部命令; - 多步操作合并为一条:在命令内部用
add-transaction逐项排队,结束时调用end-transaction,即可把连续多次变更折叠成一条历史记录; - 不可视化的关联变更:需要"跟着某条主条目一起撤销"但不想单独出现在面板中的操作,使用
log-sub-item挂载子条目; - 失败的原子性:确保在抛出
HookBreak前不要手动 close 日志,让基类的onCatchApply统一执行delete-log回滚; - 尊重文档隔离:所有历史操作都绑定
elementor.documents.getCurrent(),切换文档后历史上下文随之切换。
9.3 扩展阅读
- 组件级文档:Document 组件说明(含 Container 参数约定与
findViewById等工具方法) - 命令实现:assets/dev/js/editor/document/history/commands
- 历史管理器:modules/history/assets/js/history/manager.js
- 面板视图:modules/history/assets/js/history/panel-tab.js
- 文档模型(持有 history 实例):assets/dev/js/editor/components/documents/document.js