浏览器特性深度解析:从兼容性到性能优化实战
项目初期的技术选型
上个月接了个移动端的活动页需求,要实现一个类似“刮刮卡”的交互效果——用户手指滑动区域,底下隐藏的内容逐渐显现。一开始我第一反应是用 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 本身绘制快,但每帧都调用 arc 和 fill 还是有点吃力。我尝试了两个优化:
- 一是加防抖,但刮奖需要连续性,防抖会导致轨迹断断续续,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;
}
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的轻量级刮刮卡实现方式,欢迎评论区交流!

暂无评论