Elementor 编辑器历史记录组件解析:document/history 命令体系与撤销/重做实现

原创2026-09-16 19:49:271,907 阅读
文章标签:前端CMS低代码UI组件

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' );
}

流程可归纳为五步:

  1. 未开启事务则直接返回(幂等保护);
  2. 用 mergeTransactions() 把多个容器/多次变更合并(见前文,同一容器只保留最后一次 new 值);
  3. 从第一条事务取 type,标题取自第一条;涉及多个容器时统一标题为"Elements";
  4. 先 start-log 开启主条目,再把合并后的每个事务条目作为 log-sub-item 追加,最后 end-log 收尾;
  5. 无论成功与否,最后都会 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 );
}

生命周期如下:

  1. 初始化:子类重写 getHistory() 返回历史元数据对象(含 type、title、subTitle、restore 等,见 create.js、delete.js、duplicate.js、move.js 等元素的写命令);若命令不产生历史,可返回 false 或通过 options.useHistory = false 关闭;
  2. 运行前:onBeforeRun 自动 start-log,得到 historyId;
  3. 运行后:onAfterRun 自动 end-log 闭合日志;
  4. 失败回滚:若执行过程中抛出了 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 扩展阅读

登录后查看全文
elementor