CKEditor 4 表格表头属性切换实战:修复"仅有单行表头时无法移除表头"问题(1397)
CKEditor 4 表格表头属性切换实战:修复"仅有单行表头时无法移除表头"问题(#1397)
导读
本文围绕 CKEditor 4 表格(table)插件的"表头属性(Headers)"切换功能,深入讲解一个曾被官方记录为 bug(issue #1397)的场景:当表格只有一行表头(<thead> 中仅含一行 <th>)时,通过"表格属性"对话框将表头切换为"无(none)",旧版本会抛出 TypeError 且表头无法正确转换为普通单元格。文章将以仓库中的手动测试用例 onlyrowheader.md 为主线,结合 table 对话框源码 与自动化测试用例,讲清表头属性(none / row / col / both)的四种取值、底层 DOM 转换逻辑(th↔td、scope 属性、<thead>↔<tbody> 移动),以及修复前后行为差异。读完本文,你将能够:复现该缺陷、定位其根因、验证 4.11.4 及后续版本中的修复效果,并理解如何为自定义表格插件编写同类测试。
1. 场景复现:一份针对 #1397 的手动测试用例
在仓库 tests/plugins/table/manual/onlyrowheader.md 中,官方提供了一个可直接在浏览器中运行的手动测试用例,专门用于验证 #1397 的修复情况。该文档以 Bender 测试元数据开头:
@bender-tags: 4.11.4, bug, table, 1397
@bender-ui: collapsed
@bender-ckeditor-plugins: wysiwygarea,toolbar,table,sourcearea,dialogadvtab,contextmenu
这三行元数据说明:该用例归属于 4.11.4 版本迭代,类型为 bug,涉及模块为 table,对应 issue 编号 1397;编辑器界面以折叠(collapsed)形式呈现;测试环境需要加载 wysiwygarea、toolbar、table、sourcearea、dialogadvtab、contextmenu 这些插件——其中 table 提供表格相关命令与对话框,contextmenu 提供右键菜单(表格属性入口之一),dialogadvtab 负责对话框的"高级(Advanced)"选项卡。
1.1 测试步骤与预期行为
文档给出的操作步骤非常简洁:
- 打开浏览器开发者控制台(Open developer console)。
- 在表格上打开右键菜单(context menu),选择"表格属性(Table Properties)"。
- 将"表头(Headers)"选项改为"无(None)",点击"确定(OK)"。
预期结果(Expected):表格表头转换为普通单元格。
非预期结果(Unexpected,即修复前会出现的现象):
- 表头没有变成普通单元格;
- 控制台抛出
TypeError。
1.2 配套的 HTML 测试载体
与 Markdown 描述配套的 onlyrowheader.html 定义了具体的表格结构,这也是触发 bug 的最小复现数据:
<table border="1" cellspacing="1" cellpadding="1" style="width:500px;">
<thead>
<tr>
<th scope="row"><br></th>
<th scope="col"><br></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
这段 HTML 的关键特征正是 bug 名称"only row header(仅有行表头)"所指:整张表只有一个 <thead> 区块,其中只有一行,两个单元格均为 <th>,而 <tbody> 是空的。注意两个 <th> 的 scope 分别是 row 和 col——这种结构对应表头属性取值为 row(仅行表头)时的典型形态(详见第 3 节)。
2. 表头属性对话框:四个取值与语义
"表格属性(tableProperties)"对话框由 table 插件 注册:CKEDITOR.dialog.add( 'tableProperties', this.path + 'dialogs/table.js' ),对话框本体实现在 plugins/table/dialogs/table.js。其中控制表头行为的核心控件是信息(info)选项卡里的 selHeaders 下拉框,其四个取值在 对话框源码 中定义:
| 取值 | 语义 | 生成的 HTML 结构 |
|---|---|---|
''(none) |
无表头 | 所有单元格均为 <td>,无 <thead>、无 scope |
'row' |
行表头(仅首行) | 首行放入 <thead>,单元格为 <th scope="col"> |
'col' |
列表头(每行首列) | 每行首单元格为 <th scope="row">,无 <thead> |
'both' |
行列双表头 | 首行进 <thead> 且为 <th scope="col">,其余行首列 <th scope="row"> |
从源码结构看,row / both 会创建 <thead> 包裹首行,并将该行单元格 renameNode( 'th' ) 且设置 scope="col";而 col / both 会把每行第一个单元格改为 <th scope="row">。这正是第 3 节转换逻辑的入口。
3. 底层转换逻辑:th ↔ td、thead ↔ tbody 与 scope 属性
对话框的 onOk 回调(plugins/table/dialogs/table.js)负责把用户选择落地为真实的 DOM 变更。整个表头处理分为三大块:
3.1 从"无表头"切换到"行表头"(headers == 'row' || headers == 'both')
当表内尚无 <thead> 时,代码取 <tbody> 的第一行:
- 若无
<thead>,先创建并insertBefore( tbody )插入; - 遍历首行的每个子节点,跳过书签节点(
cke-bookmark),将<td>renameNode( 'th' )并setAttribute( 'scope', 'col' ); - 最后把该行
remove()后append到<thead>中。
3.2 从"行表头"切回"无表头"(#1397 修复核心)
当表内已有 <thead> 而用户选择 none 或 col 时,执行反向转换(源码位置):
while ( thead.getChildCount() > 0 ) {
theRow = thead.getFirst();
for ( i = 0; i < theRow.getChildCount(); i++ ) {
var newCell = theRow.getChild( i );
// In case when header is replaced to td element,
// check if the replaced cell should contain a 'row' scope (#2881).
if ( shouldReplaceThByTd( newCell, headers, i ) ) {
newCell.renameNode( 'td' );
newCell.removeAttribute( 'scope' );
} else {
newCell.setAttribute( 'scope', 'row' );
}
}
// Append the row to the start (#1397).
tbody.append( theRow, true );
}
thead.remove();
其中辅助函数 shouldReplaceThByTd 定义在 对话框源码顶部:
function shouldReplaceThByTd( cell, headers, index ) {
return cell.type == CKEDITOR.NODE_ELEMENT && ( !headers || index !== 0 );
}
这就是 #1397 修复的关键:thead 中可能包含不止一行,循环会逐行取出并搬回 tbody,但旧版本代码只处理了"第一个 <thead> 行",且 tbody.append( theRow, true ) 中的第二个参数 true 表示插入到 tbody 开头(toStart),确保搬回的行保持原有顺序(第 216 行的注释 // Append the row to the start (#1397) 直接标注了该修复)。若没有这段逐行循环与 toStart 插入逻辑,"仅一行表头"场景下就会出现单元格未被重命名、甚至抛出 TypeError 的缺陷。
当目标是 col 时(即仅保留列表头),从 <thead> 搬出的首行中,除第一个单元格外的其余单元格会被转为 <td> 并移除 scope,而第一个单元格保留并设置 scope="row",从而与"列 表头"的语义一致。
3.3 列方向切换:首列单元格的批量转换
若表内有"列 表头"(this.hasColumnHeaders 为真)且新选择不含 col,则遍历所有行,将 tbody 行中每行第一个单元格 renameNode( 'td' ) 并移除 scope(源码位置);反向操作则把每行首单元格改为 <th> 并设置 scope(both 时首行设 scope="col",其余行设 scope="row",参见 #2996)。
hasColumnHeaders 的判定逻辑在 selHeaders 控件的 setup 回调中(源码位置):遍历所有行检查每行首单元格是否为 <th>,全部为 <th> 才视为"已有列表头";同时根据 selectedTable.$.tHead 是否存在,把下拉框初始值设为 both / row / col / 空值。
4. 自动化测试如何锁定该缺陷
除了手动用例,仓库还有多层自动化测试覆盖表头转换行为:
4.1 针对 #1397 的回归测试
table.js 中名为 test table dialog error when only row is header 的用例,直接复刻了"仅有单行表头"场景:
// (#1397)
'test table dialog error when only row is header': function() {
var bot = this.editorBots.editor;
bot.setHtmlWithSelection(
'<table border="1" cellspacing="1" cellpadding="1" style="width:500px;">' +
'<thead>' +
'<tr>' +
'<th>^<br></th>' +
'<th><br></th>' +
'</tr>' +
'</thead>' +
'<tbody>' +
'</tbody>' +
'</table>'
);
bot.dialog( 'tableProperties', function( dialog ) {
dialog.setValueOf( 'info', 'selHeaders', 'none' );
dialog.fire( 'ok' );
dialog.hide();
assert.isNull( dialog.parts.dialog.findOne( 'th' ) );
assert.isTrue( !!dialog.parts.dialog.findOne( 'td' ) );
} );
}
它通过 bender 测试框架打开 tableProperties 对话框,把 selHeaders 设为 none 后触发 ok,最后断言:对话框 DOM 中不再存在 <th>(assert.isNull( ...findOne( 'th' ) )),且至少存在一个 <td>。若修复缺失,这两条断言会因 TypeError 或 DOM 未变化而失败,从而把 #1397 永久锁死在回归线上。
4.2 表头矩阵测试:四种取值 × 四种目标的 16 种组合
headers1row1col.js、headers1row2col.js、headers2row1col.js、headers2row2col.js 四个文件分别覆盖 1×1、1×2、2×1、2×2 尺寸的表格,每个文件都借助共享辅助函数 testheadersmanipulation.js 跑满"当前取值 → 目标取值"的 16 种组合:
function testHeadersManipulation( input, expected ) {
return function() {
var bot = bender.editorBots.editor;
bot.setHtmlWithSelection( CKEDITOR.document.findOne( '.' + input ).getHtml() );
bot.dialog( 'tableProperties', function( dialog ) {
dialog.setValueOf( 'info', 'selHeaders', expected );
dialog.fire( 'ok' );
dialog.hide();
expected = CKEDITOR.document.findOne( '.' + expected ).getHtml();
assert.beautified.html( expected.replace( '^', '' ),
dialog.getParentEditor().getData(), { fixStyles: true } );
} );
};
}
该辅助函数从测试 HTML 页面中按 CSS 类名取输入/期望 HTML,执行对话框操作后,用 assert.beautified.html 对比编辑器输出数据与期望结构。由于每个组合都校验了最终的 <th>/<td> 分布与 scope 属性,这一矩阵测试为"任意表头状态切换"提供了系统性的保障。
5. 修复记录与版本边界
官方变更日志 CHANGES.md 中明确记录了该修复:
#1397:修复了"使用对话框从仅有一行表头的表格中移除表头时会抛出错误"的问题(Fixed: Using the dialog to remove headers from a table with one header row only throws an error)。
结合手动用例的 @bender-tags: 4.11.4 标记可知:该修复随 CKEditor 4.11.4 版本发布。因此适用前提是:若你的项目使用 4.11.4 之前的版本,且表格仅含单行 <thead> 表头,通过"表格属性 → 表头 → 无"移除表头时会触发 TypeError;升级到 4.11.4 及以上版本即可获得修复。
需要说明的是,本仓库当前的 table 插件源码已包含第 3.2 节所示的逐行搬移与 toStart 修复逻辑,table 对话框 与 table 插件注册 的当前实现即为修复后的最终形态。
6. 复现与验证:在本地仓库跑通该用例
若想在本地验证修复效果,可参考以下方式(仓库为只读,仅用于查看与运行测试):
- 手动验证:用浏览器打开 onlyrowheader.html,按第 1.1 节的步骤操作——在表格上右键 → 表格属性 → 表头改为"无" → 确定。修复后应看到
<th>全部变成<td>,控制台无TypeError。 - 自动化验证:本项目使用 Bender 测试框架(见 bender.js 与 bender-runner.config.json)。运行
tests/plugins/table/table.js中的test table dialog error when only row is header用例及headers*系列矩阵用例,即可验证th→td转换、scope移除与<thead>移除等全部断言。
7. 小结:从一条 bug 用例读懂表头转换设计
onlyrowheader.md 虽然只是一份 13 行的手动测试说明,但它背后牵出了 CKEditor 4 表格表头机制的一整套设计:
- 表头属性有
none/row/col/both四种取值,分别对应<thead>的有无、<th>的分布与scope属性(col/row)的组合; - 转换由 table 对话框 的
onOk统一完成,核心是renameNode重命名、scope属性增删、<thead>与<tbody>之间的行搬移; - #1397 的根因在于"仅有一行表头"时旧逻辑未逐行处理
<thead>行,修复点正是tbody.append( theRow, true )的逐行toStart搬移; - 手动用例 + 自动化断言 + CHANGES.md 变更记录三者相互印证,构成了 CKEditor 4 严谨的回归防护体系。
对开发者而言,这份用例还提供了一个很好的模板:当为表格类插件实现"属性对话框驱动 DOM 结构变更"的功能时,可以仿照它同时编写"最小复现数据 + 手动步骤 + 自动化断言"三层测试,确保结构转换类缺陷能被快速定位并永久回归防护。