Alpine.js + Chart.js 踩坑记:一次 Maximum Call Stack Exceeded 排查之旅

背景

给一个 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

猜了一圈,全错

因为界面完全不更新,我怀疑过很多地方:

  1. 串口帧解析状态机有 bug------确实有一个(0xFF 帧后面的 ASCII 字节被误吞),但修了之后曲线还是不更新。

  2. Canvas 尺寸是 0 ------把 canvas 改成 absolute inset-0,没变化。

  3. chart.update('none') 不触发重绘 ------把整个 chart.data 对象替换掉再 update(),没变化。

  4. chart.resize() 触发布局递归 ------删掉 resize(),没变化。

  5. setTimeout(0) 推迟到下一个事件循环------没变化。

  6. Alpine.raw() 剥离 Proxy------没用,因为 Alpine 对嵌套数组元素的 Proxy 是递归的。

  7. queueMicrotask 推迟所有响应式操作------只把报错往后推了一步。

转折点:排除法

几轮瞎猜无果后,我决定系统性地做二分排除。

第一步:把 onData 回调体全清空 。就留 void data

→ 没报错。说明 IPC 机制本身正常。

第二步:往模块级变量(非 Alpine 数据)push 字符串和数字

→ 没报错。说明 Array.push 没问题。

第三步:恢复 this.fmtTime(data.timestamp)(读 Alpine 组件的 method)

→ 没报错。说明读 Alpine method 也没触发。

第四步:加 queueMicrotask(() => { this.historyData.push(...) })(在微任务里写 Alpine 响应式数组)

→ 没报错。说明延迟到微任务的写法安全。

第五步:在微任务里调 _scheduleChartUpdate()setTimeoutthis.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, 安全

教训

  1. Proxy 响应式框架(Vue / Alpine)+ Chart.js,chart 实例绝对不能放 data() 。存到 data() 外的变量就行。

  2. 当排查一个很诡异的递归 bug 时,不要凭经验猜。把代码一步步注释掉,逐步加回来,通常比搜索引擎和文档更快找到问题所在。

  3. 前端的 "Maximum call stack size exceeded" 如果出现在 cdn.min.js 的 Proxy 陷阱里,大概率是某个第三方库实例被套了响应式 Proxy,框架和库在互相触发对方的 get/set 陷阱。

相关推荐
郝学胜-神的一滴1 小时前
干货版《算法导论》05:从集合接口到排序
开发语言·数据结构·c++·程序人生·算法·排序
之歆1 小时前
Day15_JavaScript DOM 事件完全指南:从基础到实战(下)
开发语言·javascript·ecmascript
顾凌陵1 小时前
Python 数据可视化实战
开发语言·python·信息可视化
星恒随风1 小时前
从0开始的操作系统(3)
开发语言·笔记·学习
开发者联盟league1 小时前
pip install出现报错ERROR: Cannot set --home and --prefix together
开发语言·python·pip
_codemonster1 小时前
JSP 、Thymeleaf 、 JavaScript 和Vue
java·javascript·vue.js
杖雍皓1 小时前
Markstream-VUE:构建高性能流式 Markdown 渲染器
前端·javascript·vue.js·markdown·流式输出
FlagOS智算系统软件栈1 小时前
众智FlagOS完成腾讯混元MT2多语翻译模型全系列多芯片适配:英伟达/华为/平头哥三芯开箱即用
开发语言·人工智能·开源
東隅已逝,桑榆非晚1 小时前
C语言内存函数
c语言·开发语言·笔记·算法