浏览器特性深度解析:从兼容性到性能优化实战

皇甫明宇 移动 阅读 1,087
赞 27 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个移动端的活动页需求,要实现一个类似“刮刮卡”的交互效果——用户手指滑动区域,底下隐藏的内容逐渐显现。一开始我第一反应是用 Canvas,毕竟这种像素级控制 Canvas 最拿手。但后来一想,这页面还要嵌在微信里,加载速度得快,而且只是简单遮罩擦除,用 Canvas 反而重了。

浏览器特性深度解析:从兼容性到性能优化实战

于是转头研究浏览器原生支持的特性。查了下,pointer-events 配合 mask 能不能搞?试了下发现移动端兼容性太差,iOS 13 以下直接歇菜。最后锁定了 touchmove + 动态绘制遮罩层的方案——用一个绝对定位的 div 当遮罩,监听 touch 事件动态修改它的背景色或透明度区域。虽然不是最优雅,但兼容性好,代码也简单。

又踩坑了,touchmove 滚动失效

写完基础逻辑,本地测试没问题:手指滑动,遮罩被“擦掉”一块。但一上真机,问题来了——页面整体跟着手指上下滚动!用户想刮奖,结果整个页面往上跑了,体验极差。

我一开始以为是没加 preventDefault(),赶紧补上:

element.addEventListener('touchmove', (e) => {
  e.preventDefault();
  // 绘制逻辑
});

结果 iOS 上还是不行。折腾了半天才发现,Safari 对 passive: true 的默认行为处理得很严格。现代浏览器为了提升滚动性能,默认把 touch 事件设为 passive(即不能阻塞滚动),所以即使你写了 preventDefault(),它也会忽略并报 warning。

解决办法是显式声明 passive: false

element.addEventListener('touchmove', handler, { passive: false });

但注意,这只能在需要阻止滚动的元素上加,不能全局加,否则会影响页面其他区域的正常滚动。我们只在刮奖区域加了这个配置,其他地方保持默认。

核心代码就这几行

最终的实现其实很轻量。结构上就是一个容器,里面放内容层和遮罩层:

<div class="scratch-area">
  <div class="content">恭喜中奖!</div>
  <canvas id="mask" class="mask"></canvas>
</div>

为什么最后又用了 Canvas?因为纯 DOM 方案(比如用多个 div 模拟擦除点)在快速滑动时会有明显卡顿,尤其安卓低端机。Canvas 虽然初始化稍重,但绘制性能稳定得多。

关键 JS 逻辑如下:

const canvas = document.getElementById('mask');
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();

// 设置 canvas 尺寸
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);

// 初始填充灰色遮罩
ctx.fillStyle = '#ccc';
ctx.fillRect(0, 0, rect.width, rect.height);

let isDrawing = false;

function startDraw(e) {
  isDrawing = true;
  draw(e);
}

function draw(e) {
  if (!isDrawing) return;
  const x = e.touches[0].clientX - rect.left;
  const y = e.touches[0].clientY - rect.top;
  
  // 清除圆形区域
  ctx.globalCompositeOperation = 'destination-out';
  ctx.beginPath();
  ctx.arc(x, y, 20, 0, Math.PI * 2);
  ctx.fill();
}

function stopDraw() {
  isDrawing = false;
}

canvas.addEventListener('touchstart', startDraw, { passive: false });
canvas.addEventListener('touchmove', draw, { passive: false });
canvas.addEventListener('touchend', stopDraw);

这里注意两点:一是 globalCompositeOperation = 'destination-out' 是关键,它能让绘制区域变透明;二是坐标要减去容器的 offset,不然位置会偏。

最大的坑:安卓机上的延迟和闪烁

本以为万事大吉,结果测试同事反馈:部分安卓机(尤其是三星老机型)刮的时候有明显延迟,甚至偶尔闪一下白屏。

排查发现,问题出在频繁触发 touchmove 导致主线程卡顿。虽然 Canvas 本身绘制快,但每帧都调用 arcfill 还是有点吃力。我尝试了两个优化:

  • 一是加防抖,但刮奖需要连续性,防抖会导致轨迹断断续续,pass。
  • 二是改用 requestAnimationFrame 批量处理触摸点,但实现复杂,收益不大。

最后折中方案:降低绘制频率。通过记录上次绘制时间,如果两次 touchmove 间隔小于 16ms(约 60fps),就跳过。实测后流畅度提升明显,且轨迹基本连贯:

let lastDrawTime = 0;
function draw(e) {
  if (!isDrawing) return;
  const now = Date.now();
  if (now - lastDrawTime < 16) return; // 节流
  lastDrawTime = now;
  // ...绘制逻辑
}

不过这个方案在超快速滑动时还是会丢点,但用户感知不强,属于“能接受的小瑕疵”。毕竟不是绘图软件,没必要追求完美轨迹。

回顾与反思

整体来看,这个方案在主流机型上表现合格:iOS 12+、安卓 8+ 都能流畅运行,加载也快(Canvas 初始化不到 50ms)。唯一没彻底解决的问题是极低端安卓机(比如 2GB 内存的老红米)偶尔卡顿,但我们加了 loading 状态和 fallback 提示(“若无法刮开,请点击下方按钮”),兜底做得还行。

如果重做,可能会考虑 WebAssembly + WebGL 的方案,但对这种一次性活动页来说,ROI 太低。现在的方案代码不到 100 行,维护成本低,出了问题也能快速定位。

另外提醒一点:别忘了在 CSS 里加 touch-action: none; 到刮奖区域,可以进一步防止浏览器默认手势干扰。虽然 JS 里已经 preventDefault 了,但多一层保险总没错。

.scratch-area {
  touch-action: none;
  position: relative;
  overflow: hidden;
}

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的轻量级刮刮卡实现方式,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论