返回博客

微信小程序骨架屏 + Canvas 2D:onReady 已过,画布永远空白

给小程序页面加上骨架屏之后,地图上的自定义标注图标全部消失,换回了微信默认的红色图钉。DevTools 里反复刷新、真机测也一样——骨架屏消失后地图上空空如也,既没有报错,onReady 日志也正常打印。

这种"无报错、无异常、就是不显示"的症状,往往意味着某段绘制代码悄悄 no-op 了

现象

页面结构是经典的骨架屏切换模式:

<view wx:if="{{isSkeleton}}"><!-- 骨架占位 --></view>
<view wx:else class="page-content">
  <map id="storeMap" markers="{{storeList}}" />
  <canvas type="2d" id="markerCanvas" class="marker-canvas"></canvas>
</view>

isSkeleton 初始值为 true,接口返回后改为 false。Canvas 2D 的绘制写在 onReady 里:

onReady: function () {
  wx.createSelectorQuery().in(this)
    .select('#markerCanvas')
    .fields({ node: true, size: true })
    .exec(function (res) {
      var canvas = res[0].node; // 这里拿到 undefined
      // 后续绘制全部跳过
    });
}

日志里 res[0].node 打出 undefined,画布从未被写入任何像素。骨架屏翻转之后 canvas 节点虽然挂载了,但 onReady 一生只触发一次,再也不会重跑。

根因

wx:if条件销毁/创建——当条件为 false 时,对应子树的 DOM 节点根本不存在于布局树中。

页面生命周期顺序是:

onLoad → 渲染首帧(此时 isSkeleton=true,wx:else 子树未渲染)→ onReady → 异步数据到 → isSkeleton=false → wx:else 子树挂载

onReady 触发的那一刻,wx:else 分支还没有被渲染,#markerCanvas 节点自然不在布局树里,createSelectorQuery 理所当然返回 { node: undefined }。绘制静默跳过,之后 canvas 挂载了却永远是空白。

坑:Canvas 放在 wx:else 里,onReady 触发时它还不存在

wx:if / wx:else 会在条件变化时销毁和重建节点,而 onReady 只在页面首次渲染完成后触发一次。两者叠加,导致 Canvas 节点在 onReady 时根本不在 DOM 里,createSelectorQuery 拿到 undefined,绘制永久失效。

正解

把 canvas 节点提升到条件分支之外,让它在页面生命周期全程保持挂载:

<!-- 骨架屏和内容互斥切换 -->
<view wx:if="{{isSkeleton}}" class="page-loading">
  <!-- 骨架占位内容 -->
</view>
 
<view wx:else class="page-content">
  <map id="storeMap" markers="{{storeList}}" />
</view>
 
<!-- canvas 必须在条件分支外,确保 onReady 能获取到节点 -->
<canvas type="2d" id="markerCanvas" class="marker-canvas"></canvas>

Canvas 在视觉上可以用 position: fixed; left: -9999rpxwidth: 0; height: 0 藏起来,但结构上它始终在布局树里。onReady 触发时 createSelectorQuery 能正常拿到节点,绘制完后通过 canvas.toTempFilePath 把像素导出为临时图片路径,再赋给 marker.iconPath

另一种方案:如果骨架屏和内容只是切换显隐而非条件创建,可以改用 hidden

<view hidden="{{!isSkeleton}}" class="page-loading"><!-- 骨架 --></view>
<view hidden="{{isSkeleton}}" class="page-content">
  <map id="storeMap" markers="{{storeList}}" />
  <canvas type="2d" id="markerCanvas"></canvas>
</view>

hidden 只改 CSS display,节点始终存活,canvas 不受影响。代价是骨架屏节点常驻内存,内容复杂时权衡选用。

正解:canvas 节点提升到 wx:if/wx:else 分支外

一行 WXML 改动:把 <canvas>wx:else 内部挪到条件分支之外(或改用 hidden 代替 wx:if)。JS 侧零改动,onReady 里的 createSelectorQuery 自然就能拿到节点,绘制一次性成功。

排查速查

进 DevTools → AppData 面板,在骨架屏阶段直接查 #markerCanvas 是否存在:

  • 修复前isSkeleton: true 期间节点缺失,onReady exec 回调里 res[0]undefined
  • 修复后:首帧起节点就存在,res[0].node 为合法 HTMLCanvasElement

同时在 _drawMarkerIcon 的 exec 回调最前面加防御日志:

.exec(function (res) {
  if (!res || !res[0] || !res[0].node) {
    console.warn('[canvas] node not found, skip draw');
    cb(null);
    return;
  }
  // ...
});

这个 warn 一旦出现,根因 99% 是节点挂载时序问题。

一句话外卖

wx:if 会在条件变化时销毁节点;依赖 onReady 单次触发的 Canvas 2D 必须常驻 DOM——把它提升到所有条件分支之外,或用 hidden 代替 wx:if