后台管理系统里有个常见交互:点击"收起详情",中间的报表区域从占满整行平滑收缩到一半宽,给右侧面板腾位置。为了让过渡柔和,容器宽度加了 CSS 动画。动画本身流畅,麻烦出现在过程中和结束时:SpreadJS Designer 的功能区与表格区域出现错位、残缺,有时要再点一下别处才恢复。这篇文章拆解问题的成因,以及两种可靠的刷新做法。
为什么这个细节重要
SpreadJS 是基于 Canvas 绘制的 JavaScript 电子表格控件,Designer 在其上叠加了完整的 Ribbon 工具栏。这类组件的布局尺寸在初始化和显式刷新时确定,不会逐帧跟随容器宽度的渐变。CSS 过渡期间容器每一帧都在变化,组件内部却仍按旧尺寸绘制,错位就这样产生。对开发者来说,这是典型的"布局已被外部改变,但组件没有收到通知";对产品经理来说,动效做得越讲究,这种破绽越伤产品的专业感。
SpreadJS 的实现思路
API 文档对 designer.refresh() 的定义是"刷新设计器布局和功能区区域",并在修改容器尺寸的场景下演示了这个调用。思路由此确定:动画期间持续调用 refresh,结束后再做最后一次收尾刷新。
// 方案一:定时刷新,监听过渡结束事件停止
document.getElementById("narrow").onclick = function () {
host.style.width = "50%";
myInterval = setInterval(function () {
designer.refresh();
}, 100);
};
container.addEventListener("transitionend", function () {
clearInterval(myInterval);
designer.refresh();
});
// 方案二:已知动画时长时,到点收尾
function resizeSmooth(hostEl, timing) {
var d = GC.Spread.Sheets.Designer.findControl(hostEl);
if (!d) return;
var timer = setInterval(function () { d.refresh(); }, 100);
setTimeout(function () { clearInterval(timer); d.refresh(); }, timing);
}
方案一用 100 毫秒的 setInterval 在动画期间反复重建布局,同时在容器上监听浏览器的 transitionend 事件,动画一结束立即清除定时器并补一次最终刷新,保证落点状态正确。方案二适合已经约定好动画时长的场景:启动同样的定时器,再用 setTimeout 到点停止,不依赖事件是否可靠触发。这里还有一个实用方法 GC.Spread.Sheets.Designer.findControl(host):只要拿到宿主 DOM 元素就能取回 Designer 实例,封装通用工具函数时不必依赖外层闭包变量。
两个方案的共同点是把"容器正在变"翻译成"持续刷新",把"动画结束"翻译成"最后一次刷新"。100 毫秒是经验值:间隔太长,动画中途仍会短暂露出旧布局;太短则徒增绘制开销。
可以扩展到哪些场景
同一套思路可直接迁移到:侧边栏折叠展开、多标签页切换后的布局恢复、可拖拽分隔条的分屏报表、大屏项目的窗口自适应。页面里如果是纯 Workbook 而非 Designer,把调用换成 spread.refresh() 即可;若想覆盖一切来源的尺寸变化,可用 ResizeObserver 监听宿主元素,在回调里统一触发刷新,连按钮都可以省掉。
给开发者的关键词
SpreadJS Designer、designer.refresh()、GC.Spread.Sheets.Designer.findControl、designer.getWorkbook()、spread.refresh()、CSS transition、transitionend、setInterval 与 setTimeout 定时刷新、ResizeObserver、Canvas 重绘、host 元素尺寸变化。
小结
Canvas 类表格控件不会自动追随 CSS 过渡的每一帧,这是渲染模型决定的边界,而不是缺陷。"布局被外部改变后主动调用 refresh"应当成为集成 SpreadJS 时的固定习惯。把这个细节处理干净,缩放动画才能既流畅又不出戏,用户看到的始终是一张随时正确的表格。