返回博客

WebGL 组件「dev 坏、prod 好」:被 StrictMode 毒化的 canvas

给 hulianui 做一批 WebGL 装饰背景(Silk / Iridescence / Threads / Orb / LiquidChrome,底层用 ogl)时,撞上一个反直觉的 bug:开发环境下组件渲染成一个空白方块,生产构建却好端端。

最容易得出的错误结论是「shader 在 dev 下编译有问题」或「ogl 版本的锅」。都不是。

现象

dev 下打开页面,WebGL 背景是空的,控制台一条:

Uncaught TypeError: Cannot read properties of undefined (reading 'forEach')
    at Program.use ...
    at Mesh.draw ...
    at Renderer.render ...

错误恰好只报一次errCount === 1)。生产构建零报错。

根因:canvas 只能有一个 context,而它被毒化了

两条事实凑在一起就出事:

  1. 一个 <canvas> 元素终生只能持有一个 WebGL context。 一旦这个 context 通过 WEBGL_lose_context 扩展被 loseContext() 丢弃,之后对同一个 canvas 调 getContext() 永远返回那个已丢失的 context(isContextLost() === true),不会给你一个新的。

  2. React StrictMode(Next.js dev 默认开启)会对每个 effect 走一遍 mount → cleanup → mount。 而我的组件——和绝大多数 WebGL 封装一样——在 useEffect 的 cleanup 里很「负责任」地调了 loseContext() 来防止 context 泄漏。

把这两条按 StrictMode 的时序串起来:

  • mount 1:在 canvas 上建好 context,一切正常。
  • cleanup 1loseContext() —— 这一刻 canvas 被永久毒化
  • mount 2new Renderer({ canvas }) 复用同一个 canvas ref,拿到的是那个死掉的 context → linkProgram 失败 → ogl 构造器提前返回、没建出 uniformLocations / attributeLocations → 首帧 render 时对 undefined.forEach → 崩。

生产构建没有 StrictMode,只 mount 一次,永远走不到 mount 2 —— 所以「dev 坏、prod 好」。这是个极易误判的信号:它看起来像环境差异,其实是生命周期语义差异。

怎么坐实

别猜,两步验证:

// 1. canvas 的 context 是不是死了?
canvas.getContext("webgl2")?.isContextLost(); // true ⇒ 坐实毒化
 
// 2. 排除 shader 嫌疑:单独编译链接同一套 shader
gl.getProgramParameter(program, gl.LINK_STATUS); // true ⇒ shader 没问题,是 context 死了

LINK_STATUS === true 这一步很关键——它把「shader 写错了」这条岔路彻底切掉,把矛头钉死在 context 上。

坑:把一次性资源当可复用资源用

<canvas> 终生只能持有一个 WebGL context;cleanup 里 loseContext()永久毒化它。StrictMode(dev 默认)双挂载复用同一张 canvas → mount 2 拿到死 context → linkProgram 失败 → 崩。prod 无 StrictMode 只挂一次,所以「dev 坏、prod 好」。

正解:每次挂载新建 canvas,别复用

问题的根源是把 canvas 这个一次性资源当可复用资源用了。修法是让 helper 自己掌管 canvas 的生命周期,绝不把 canvas 暴露给消费者去复用:

  • ref 挂在外层容器 <div>,不挂在 <canvas> 上。
  • 每次 effect 执行时 document.createElement("canvas") 新建一个 canvasappendChild 进容器。
  • cleanup 时 loseContext() + canvas.remove(),把这个用过的 canvas 整个丢弃。

下次挂载拿到的是一张全新的 canvas,永远不会复用被毒化的那张。loseContext() 照样调(防泄漏的本意没丢),但因为 canvas 是新的,毒化不到下一条命。

顺手还修了两个让它更皮实的点:

  • RAF 顺序:先 requestAnimationFrame(loop)render(),单帧抛错不会把整个循环杀掉。
  • render 包 try/catch:持续失败就停渲染,而不是把整页面带崩。
正解:helper 自持 canvas 生命周期

ref 挂在外层 <div>、不挂 canvas;每次 effect createElement("canvas") 新建并 append,cleanup 时 loseContext() + canvas.remove()。下次挂载是全新 canvas,毒化不到下一条命,防泄漏的本意也没丢。

一句话外卖

loseContext() 的本意是防「too many WebGL contexts」泄漏,但在 React 重挂载语义下复用同一张 canvas,等于自残。让 helper 自持 canvas 生命周期、按挂载新建,既防泄漏又防毒化。

凡是 React 里封装 WebGL(ogl / three / 原生都一样),别把 canvas 当可复用的 ref 交出去。