背景
给一个 RS485 激光测距传感器写上位机,技术栈是 Electron + 单页 HTML(Alpine.js + Tailwind CSS CDN)。核心功能都顺利跑通了,最后加历史数据折线图时卡了两天------曲线死活不会自动更新。
症状
打开界面连接传感器后:
- 折线图最初能画出来(从 SQLite 加载的历史数据)
- 新的数据帧收到后,曲线纹丝不动
- 切换到「表格」再切回「折线」,曲线会更新一次(因为切回来时重新
new Chart()了) - 开发者工具控制台每次数据帧到达时都会喷错误
错误分两类。第一帧:
RangeError: Maximum call stack size exceeded
at cdn.min.js:5 ← Alpine 的 Proxy 代码
at color.esm.js:241 ← Chart.js 的颜色模块
at index.umd.ts:50 ← Chart.js 内部
后续每一帧:
TypeError: Cannot set properties of undefined (setting 'fullSize')
at IpcRenderer.handler
猜了一圈,全错
因为界面完全不更新,我怀疑过很多地方:
-
串口帧解析状态机有 bug------确实有一个(0xFF 帧后面的 ASCII 字节被误吞),但修了之后曲线还是不更新。
-
Canvas 尺寸是 0 ------把 canvas 改成
absolute inset-0,没变化。 -
chart.update('none')不触发重绘 ------把整个chart.data对象替换掉再update(),没变化。 -
chart.resize()触发布局递归 ------删掉resize(),没变化。 -
setTimeout(0)推迟到下一个事件循环------没变化。 -
Alpine.raw()剥离 Proxy------没用,因为 Alpine 对嵌套数组元素的 Proxy 是递归的。 -
queueMicrotask推迟所有响应式操作------只把报错往后推了一步。
转折点:排除法
几轮瞎猜无果后,我决定系统性地做二分排除。
第一步:把 onData 回调体全清空 。就留 void data。
→ 没报错。说明 IPC 机制本身正常。
第二步:往模块级变量(非 Alpine 数据)push 字符串和数字。
→ 没报错。说明 Array.push 没问题。
第三步:恢复 this.fmtTime(data.timestamp)(读 Alpine 组件的 method)。
→ 没报错。说明读 Alpine method 也没触发。
第四步:加 queueMicrotask(() => { this.historyData.push(...) })(在微任务里写 Alpine 响应式数组)。
→ 没报错。说明延迟到微任务的写法安全。
第五步:在微任务里调 _scheduleChartUpdate() → setTimeout → this.updateChart()。
→ 报错回来了!
第六步:进一步缩窄 ------在同样的 setTimeout 里直接调 this.createChart()(不走 updateChart)。
→ 没报错,而且曲线开始正常更新!
所以罪魁祸首就是这个调用链的一环:
queueMicrotask → setTimeout → _chartInstance.update()
_chartInstance 是放在 Alpine data() 里的 this._chart。
根因
问题出在 Alpine 和 Chart.js 都用了 Proxy。
- Alpine 对
data()返回的所有 属性套 Proxy,包括_chart这个 Chart.js 实例 - Chart.js 在
update()内部也会遍历自己的data.datasets[].data数组,这些数组 Chart.js 内部也可能用 Proxy 管理 - 当 Alpine-proxied 的
_chart调用update(),Chart.js 访问内嵌的 Proxy 对象 → 触发 Alpine 的get陷阱 → Alpine 运行 effects → 回到 Chart.js → 栈溢出
本质上,Alpine 试图把 Chart.js 实例也当响应式数据追踪,而 Chart.js 内部的数据结构同样被 Proxy 套了一层。两层 Proxy 互相嵌套访问,形成死循环。
上网一搜,发现 Vue.js + Chart.js 也有完全一样的问题。Vue 社区的修复方案:
javascript
// ❌ 不行:chart 放在 data() 里,Vue 套 Proxy
data() {
return { chart: null }
}
// ✅ 可以:chart 移出 data() return,Vue 不再追踪
data() {
this.chart = null // 挂 this 上但不返回
return {}
}
相当于用了 Vue 的 shallowRef:实例是非响应式的,只追踪你主动交给图表的数据。
最终方案
我们的项目用了两种都能跑的方案,最终采用的更优雅的一种:
方案一(笨但可靠):每帧重建图表
javascript
_scheduleChartUpdate() {
setTimeout(() => {
this.createChart(); // 先 destroy,再 new
}, 0);
}
每 3 秒销毁旧 Chart.js 实例、用最新数据构造新的。因为 new Chart() 不发生递归,这条路是安全的。代价是每次重建有一点肉眼看不出来的开销。
方案二(最终采用):chart 实例模块级声明 + 增量 update
javascript
// 文件顶层,Alpine.data() 外面
let _chartInstance = null;
let _chartLabels = [];
let _chartDataArr = [];
// Alpine 组件内
Alpine.data('app', () => ({
// ❌ _chart 不在这里
historyData: [], // ✅ 表格视图的响应式数据在这里
}))
// 更新逻辑
_scheduleChartUpdate() {
setTimeout(() => {
_chartInstance.data.labels = _chartLabels;
_chartInstance.data.datasets[0].data = _chartDataArr;
_chartInstance.update(); // 安全------chart 实例不是 Proxy
}, 0);
}
_chartInstance 是普通 JavaScript 变量,Alpine 碰不到它。Chart.js 的 update() 在这个干净的实例上运行,与 Alpine 的响应式系统完全隔离。
同时,所有 Alpine 响应式写操作(this.R_m = ...、this.historyData.push(...))都用 queueMicrotask 推迟到 IPC 回调栈之外执行,避免 IP C handler 内同步触发 Alpine effects。
最终数据流
IPC 消息到达
│
├── [同步] _chartLabels.push / _chartDataArr.push ← 模块级纯数组
│
└── [queueMicrotask] ← 安全出栈
├── this.R_m = ... 等 Alpine 赋值
├── this.historyData.push(...) ← 响应式, 驱动表格
└── _scheduleChartUpdate()
└── [setTimeout]
├── _chartInstance.data.labels = _chartLabels
├── _chartInstance.data.datasets[0].data = _chartDataArr
└── _chartInstance.update() ← 零 Proxy, 安全
教训
-
Proxy 响应式框架(Vue / Alpine)+ Chart.js,chart 实例绝对不能放
data()里 。存到data()外的变量就行。 -
当排查一个很诡异的递归 bug 时,不要凭经验猜。把代码一步步注释掉,逐步加回来,通常比搜索引擎和文档更快找到问题所在。
-
前端的 "Maximum call stack size exceeded" 如果出现在
cdn.min.js的 Proxy 陷阱里,大概率是某个第三方库实例被套了响应式 Proxy,框架和库在互相触发对方的 get/set 陷阱。