Building an interactive WebGL water hero in React — and the four bugs it cost me

Building an interactive WebGL water hero in React — and the four bugs it cost me

在 React 中构建交互式 WebGL 水波纹 Hero 区域——以及我踩过的四个坑

I wanted a hero section that reacts to being touched. Not a video loop of water, not a looping GIF — an actual surface that ripples where you click it. The effect itself took an afternoon. Everything around it took considerably longer, and that is the part worth writing down. Here is what it ended up looking like: live demo.

我想要一个能够响应触摸的 Hero(首屏)区域。不是循环播放的水波视频,也不是循环的 GIF,而是一个当你点击时会产生涟漪的真实表面。实现这个效果本身只花了一个下午,但围绕它所做的一切工作却耗费了相当长的时间,而这正是值得记录的部分。最终效果如下:在线演示。

The simulation

模拟实现

The heavy lifting is done by jquery.ripples, a small plugin that runs a shallow-water simulation in WebGL. It stores a height field in a texture and steps it with the classic wave update:

繁重的工作由 jquery.ripples 完成,这是一个在 WebGL 中运行浅水模拟的小型插件。它将高度场存储在纹理中,并使用经典的波动更新算法进行计算:

float average = ( texture2D(texture, coord - dx).r + texture2D(texture, coord - dy).r + texture2D(texture, coord + dx).r + texture2D(texture, coord + dy).r ) * 0.25;
info.g += (average - info.r) * 2.0; // velocity
info.g *= 0.995; // damping
info.r += info.g; // height

Then it refracts the background image along the surface normal. That is the whole trick.

然后,它沿着表面法线对背景图像进行折射。这就是全部的诀窍。

Getting a jQuery plugin into React

将 jQuery 插件引入 React

The plugin is from an era when everything attached itself to a global jQuery. That is awkward inside a bundler, but not hard — import it dynamically and set the global first:

这个插件来自一个所有东西都挂载到全局 jQuery 的时代。在打包工具中使用它会很别扭,但并不难——动态导入它并先设置全局变量即可:

useEffect(() => {
  const node = surfaceRef.current;
  if (!node) return;
  let $el = null;
  let cancelled = false;

  async function initRipples() {
    const { default: $ } = await import("jquery");
    window.jQuery = $;
    window.$ = $;
    await import("jquery.ripples");
    if (cancelled) return;
    $el = $(node);
    $el.ripples({ resolution: 512, dropRadius: 24, perturbance: 0.026 });
  }

  initRipples();
  return () => {
    cancelled = true;
    if ($el) $el.ripples("destroy");
  };
}, []);

Two things matter here. The dynamic import() means jQuery and the plugin end up in their own chunk instead of blocking first paint. And the cancelled flag matters because in StrictMode the effect runs twice in development — without it you can initialise onto an element that is already being torn down.

这里有两点很重要。动态 import() 意味着 jQuery 和插件会被打包进独立的 chunk 中,而不会阻塞首屏渲染。cancelled 标志也很重要,因为在开发环境的 StrictMode 下,effect 会运行两次——如果没有这个标志,你可能会在一个已经被销毁的元素上进行初始化。

Turning the interaction off

关闭交互

By default the plugin ripples wherever your mouse moves. It looks great in a demo GIF and terrible on a real page: the water is in constant motion directly behind your headline, and the eye never settles enough to read it. So I switched its pointer tracking off and fire drops manually instead:

默认情况下,插件会在鼠标移动的任何地方产生涟漪。这在演示 GIF 中看起来很棒,但在实际页面上却很糟糕:水波在标题后不断晃动,导致读者的视线无法集中阅读。所以我关闭了它的指针追踪,改为手动触发水滴:

$el.ripples({ /* ... */ interactive: false });

const dropAt = (clientX, clientY) => {
  const rect = node.getBoundingClientRect();
  $el.ripples("drop", clientX - rect.left, clientY - rect.top, 32, 0.1);
};

node.addEventListener("mousedown", (e) => dropAt(e.clientX, e.clientY));
node.addEventListener("touchstart", (e) => {
  for (const t of e.changedTouches) dropAt(t.clientX, t.clientY);
}, { passive: true });

Now the surface is still until someone deliberately touches it. Much calmer, and the interaction feels intentional rather than incidental.

现在,除非有人刻意触摸,否则表面是静止的。这样平静多了,交互感觉也更有意图,而不是随意的。

The contrast problem nobody warns you about

没人提醒你的对比度问题

Bright water plus white type is a losing combination. The caustics are almost pure white in places, and thin light type simply disappears into them. Drop shadows on the text help a little. What actually fixed it was a permanent veil across the middle of the overlay gradient:

明亮的水面加上白色的文字是一个糟糕的组合。焦散效果在某些地方几乎是纯白色的,细浅的字体会直接消失在其中。给文字添加投影有一点帮助,但真正解决问题的是在覆盖层渐变中间加了一层永久的遮罩:

<div className="absolute inset-0 bg-[linear-gradient(to_bottom, rgba(20,74,99,0.38) 0%, rgba(20,74,99,0.17) 18%, rgba(20,74,99,0.18) 44%, /* <- the bit that matters */ rgba(20,74,99,0.10) 62%, rgba(242,251,254,0.55) 90%, rgb(242,251,254) 100%)]" />

A uniform dark overlay would have killed the effect. Concentrating it where the type sits keeps the water bright at the edges and readable in the middle.

统一的深色遮罩会毁掉这个效果。将遮罩集中在文字所在的位置,既保持了边缘水面的明亮,又保证了中间文字的可读性。

The four bugs

四个 Bug

1. 100vh is not the viewport on mobile Safari

1. 移动端 Safari 上的 100vh 并非视口高度

The hero was h-screen, which compiles to height: 100vh. On iOS that resolves to the large viewport — the one you get when the URL bar is hidden. So the bottom of the hero sat underneath the URL bar, and jumped whenever the bar collapsed. 100svh — the small viewport — is always fully visible and never resizes:

Hero 区域使用了 h-screen,编译后是 height: 100vh。在 iOS 上,这会被解析为“大视口”(即隐藏 URL 栏时的视口)。因此,Hero 的底部会位于 URL 栏下方,当 URL 栏收起时,页面会发生跳动。100svh(小视口)始终完全可见且不会调整大小:

.h-hero { height: 100vh; }
@supports (height: 100svh) {
  .h-hero { height: 100svh; }
}

dvh 很诱人,但它会随着 URL 栏的隐藏而调整大小,这又会导致跳动。对于 Hero 区域,svh 是正确的选择。

2. Two fast clicks, one stale value

2. 两次快速点击,一个过期值

The cart had quantity steppers. The decrement handler looked reasonable: onClick={() => setQuantity(product.id, quantity - 1)}. Click twice quickly enough that both events land in the same React batch and both handlers compute 3 - 1 = 2. The second click does nothing. The fix is to stop passing absolute values around and resolve the change inside the updater, where the latest state is available:

购物车有数量增减器。减量处理函数看起来很合理:onClick={() => setQuantity(product.id, quantity - 1)}。如果点击速度够快,两个事件会进入同一个 React 批处理,两个处理函数都会计算出 3 - 1 = 2。第二次点击就失效了。解决方法是停止传递绝对值,改在更新器内部处理变化,那里可以获取到最新的状态:

const addItem = useCallback((id, delta = 1) => update((current) => {
  const existing = current.find((line) => line.id === id);
  if (!existing) return delta > 0 ? [...current, { id, quantity: delta }] : current;
  const quantity = existing.quantity + delta;
  return quantity <= 0 ? current.filter((line) => line.id !== id) : current.map((l) => (l.id === id ? { ...l, quantity } : l));
}), [update]);

现在按钮调用 addItem(id, +1)addItem(id, -1),两次点击总是会正确累加。

3. A preload that downloaded 64KB on every page that didn’t need it

3. 一个在不需要的页面上下载了 64KB 的预加载

I had put the sensible-looking thing in index.html: <link rel="preload" as="image" href="/water-texture.webp" type="image/webp" />. This is a single-page app. One index.html serves every route. So /shop, /cart and every product page were all downloading the hero texture — a texture that only ever renders on /. The browser even said so in the console: “The resource … was preloaded using link preload but not used within a few seconds from the window’s load event.” I deleted it. The hero’s own background-image request starts as soon as React mounts it, which is soon enough, and nothing else pays for it.

我在 index.html 中放了一个看起来很合理的预加载标签:<link rel="preload" as="image" href="/water-texture.webp" type="image/webp" />。这是一个单页应用,所有路由都由同一个 index.html 提供。因此,/shop/cart 和每个产品页面都在下载这个 Hero 纹理——而这个纹理只在 / 页面渲染。浏览器甚至在控制台里提示:“该资源使用了 link preload 预加载,但在 window load 事件后的几秒内未被使用。”我删除了它。Hero 自身的 background-image 请求会在 React 挂载时立即开始,这已经足够快了,而且不会造成额外的资源浪费。

4. 跳转了但没有滚动的 Hash 链接

Clicking [ABOUT] from /shop should land you on the home page at the About section. It navigated fine and stayed at the top. Two reasons, stacked. The target section had not mounted yet when the effect ran — so document.querySelector(hash) returned null. And once I fixed that with a retry, a smooth scrollInto...

/shop 点击 [ABOUT] 应该跳转到首页的 About 部分。页面跳转正常,但停留在顶部。原因有两个:当 effect 运行时,目标部分尚未挂载,所以 document.querySelector(hash) 返回了 null。在我通过重试修复这个问题后,平滑滚动 scrollInto...(注:原文此处中断)。