#CSSHoudini实战:用Paint/AnimationWorklet突破原生CSS限制(自定义绘制)我至今记得三年前那个下午。产品甩过来一个需求:给后台的监控大盘做个"会呼吸的网格背景",要能跟着主题色换颜色、跟着…
CSS Houdini 实战:用 Paint/Animation Worklet 突破原生 CSS 限制(自定义绘制)
CSS Houdini 实战:用 Paint/Animation Worklet 突破原生 CSS 限制(自定义绘制)
我至今记得三年前那个下午。产品甩过来一个需求:给后台的监控大盘做个"会呼吸的网格背景",要能跟着主题色换颜色、跟着视口缩放、还不能影响页面滚动。我第一反应是,这还不简单?background-image加个linear-gradient 拼格子呗。结果拼出来一屏幕的 div,DOM 节点直接飙到两千多个,滚动起来卡得像 PPT。后来我换了 SVG pattern,文件倒是小了,可要动态改颜色又得重绘整张图,改一次全页回流一次……那一周我几乎把"CSS 能不能画背景"的姿势试了个遍,最后得出结论:原生 CSS 对"程序化纹理"这种东西,基本是束手无策。正是这份挫败感,把我直接逼去翻了 Houdini 的文档。
那会儿我压根不知道,CSS 早就把"自己画背景"的能力偷偷开了一个口子,叫 CSS Houdini,更具体地说,是它里面的 Paint Worklet。我之前一直有个认知误区——觉得 CSS 能"画"的东西就 border-radius、gradient、box-shadow 那点老三样,再复杂的效果要么上 SVG、要么堆 div、要么直接贴图。直到被那个网格需求反复打脸,我才意识到:原来我能让浏览器在渲染背景的那一刻,执行我写的 JavaScript 去一笔一笔画。
这篇文章不是来给你背 Houdini 名词表的。我是真被它救过命,也真被它坑过(后面会说几个让我想摔键盘的细节),所以想用一个做过的东西,把 Paint Worklet 和 Animation Worklet 到底怎么用、能干嘛、不能干嘛,讲明白。
一、先说清楚:Houdini 到底是个啥(别被名字唬住)
Houdini 这个名字取自美国脱逃魔术师 Harry Houdini,大概是取"让 CSS 新特性逃出浏览器掌控"的意思。它本质上是一组低级 API 的集合,把浏览器渲染引擎里原本封闭的 Paint(绘制)、Layout(布局)、Composite(合成)等环节,开放了一部分给 JavaScript。
你可能会问:新的 CSS 特性在被浏览器支持之前,不是也能用 JavaScript Polyfill 顶上吗?为什么非得要 Houdini?我踩过坑之后的体感是,Polyfill 有三个绕不开的硬伤:
第一,不一定能实现。CSSOM 开放给 JS 的接口太少了,开发者只能操作 DOM、算样式,真正底层的绘制、布局规则根本碰不到。你想用 Polyfill 模拟一个"自定义背景"还算简单,但要模拟 Layout 或 Paint 阶段的渲染规则,基本是做梦。
第二,效果差、有偏差。Polyfill 是用 JS 去"模拟"CSS 特性,不是浏览器原生渲染,常常和原生表现不一致。还记得当年那个 css-scroll-snap-polyfill吗?用起来一堆限制。
第三,性能差。Polyfill 的执行时机是在 DOM 和 CSSOM 都构建完、渲染完之后,再去给元素改内联样式,这会触发重渲染甚至回流。一旦它和滚动事件绑在一起,低端机上直接掉帧掉到怀疑人生。
而 Houdini 的思路完全相反:它不是模拟,而是让你直接伸进渲染引擎里,用浏览器自己的管线去画、去布局、去做动画。其中和我们今天主题最相关的,就是 Paint API(对应 Paint Worklet)和 Animation API(对应 Animation Worklet)。
一点人话总结:Paint Worklet 让你"写代码画 CSS 背景/边框",Animation Worklet 让你"写代码控制动画怎么跑"。一个管画,一个管动。
顺便说个让我至今意难平的事:我等:has()选择器等了快十年,等conic-gradient 也等了好几年。一个 CSS 新特性从被提案到所有浏览器都支持,动辄三五年,前端这行最磨人的就是"想法有了,浏览器没给"。Houdini 最迷人的地方恰恰在这——它不让你干等标准,而是把绘制、布局、合成的钩子直接递到你手里,你想画什么自己写。当然,理想很丰满,现实是它自家支持度也参差不齐,这点后面会反复提醒你,别上头。
还有个容易被忽略的底层 API 叫 Typed Object Model。在 Houdini 之前,我们用 JS 操作样式拿到的是字符串,拼 cssText 又臭又长还容易出错;Typed OM 把每个 CSS 值包成了带类型的 JS 对象,读写都清爽。不过它对今天的主角 Paint / Animation Worklet 不是前置条件,了解即可,别被名词吓退。
二、Paint Worklet 实战:让浏览器替你画背景
先说最常用、支持度也最好的 Paint Worklet。它干的事特别纯粹:把一段 JS 注册成"画笔",然后在 CSS 里用 paint(画笔名) 调用它,浏览器会在绘制那个元素背景/边框的那一刻,跑你的代码,用 Canvas 2D 的 API 把图形画出来。
它和我们熟悉的 Canvas 不一样的地方在于——它是位图输出,且跑在独立的 Worklet 线程里。这点后面踩坑部分会重点讲,先把它用起来。
你可能会问,既然都能用 Canvas 画了,干嘛不直接 <canvas>标签?区别在于:<canvas> 是你自己管的一张位图,要响应式、要跟着变量变,就得自己写重绘逻辑还盯着尺寸;而 Paint Worklet 画的图形是"分辨率无关"的,元素拉多大它画多大,变量一变浏览器自动重绘,你啥都不用管。这正是它比裸 Canvas 省心的地方,也是它适合做背景纹理、边框这类"随容器自适应"效果的根本原因。
下面这个例子,是我当年那个"会呼吸的网格"需求的简化版:画一条上下浮动的正弦波,盖在底色之上,像个水波。
javascript// wave-paint.js —— 这就是一个 Paint Worklet 文件,单独跑在 Worklet 线程里// 注意:Worklet 里没有 window、没有 document,连 fetch 都用不了// 它是个"纯净"的绘制沙箱,只能靠 CSS 变量把数据喂进来registerPaint('wave', class { // inputProperties 声明:这个 paint 方法依赖哪些 CSS 自定义属性 // 不在这里声明的变量,paint 内部是拿不到的(这是新手最容易踩的坑) static get inputProperties() { return ['--animation-tick']; } // paint 方法就是真正的"画笔" // ctx 跟你熟悉的 Canvas 2D 上下文几乎一样;geom 是元素的几何信息(宽高) // properties 里装的就是上面声明过的那些 CSS 变量 paint(ctx, geom, properties) { // 从 CSS 变量里读出当前帧的"时间戳",把它转成数字 // properties.get 返回的是 CSSStyleValue,要显式转一下 const tick = Number(properties.get('--animation-tick')); const { width, height } = geom; // 基线放在元素高度 40% 的位置,上下浮动 10px 画一条正弦波 const initY = height * 0.4; const waveTick = tick * 2; // 一笔一笔把波形描出来 ctx.beginPath(); ctx.moveTo(0, initY + Math.sin(waveTick / 20) * 10); for (let i = 1; i <= width; i++) { ctx.lineTo(i, initY + Math.sin((i + waveTick) / 20) * 10); } ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.closePath(); // 半透明白色填充,盖在底色之上形成"浪花" ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; ctx.fill(); }});

有了画笔,接下来要在主线程把它"加载"进来,并在 CSS 里用起来。这一步和 Web Worker 的用法有点像——都是独立线程,所以如果你对"把活儿丢给后台线程"的思路感兴趣,可以顺手看看Web Workers 实战:把计算密集型任务丢给后台线程(SharedWorker 与多线程通信),里面对"主线程 vs 工作线程"的分工讲得更系统。
html<!-- index.html —— 主线程这边负责"加载"和"驱动" --><div id="wave"></div><style> #wave { width: 20%; height: 70vh; margin: 10vh auto; background-color: #ff3e81; background-image: paint(wave); }</style><script> // 先做特性检测,不支持的浏览器就老老实实用降级样式 if ('paintWorklet' in CSS) { // addModule 异步加载 Worklet 文件,返回 Promise CSS.paintWorklet.addModule('wave-paint.js'); const wave = document.querySelector('#wave'); let tick = 0; // 用 requestAnimationFrame 不停地把帧计数写进 CSS 变量 // Worklet 读不到时间,只能靠变量"喂"数据进去 requestAnimationFrame(function raf() { tick += 1; wave.style.setProperty('--animation-tick', tick); requestAnimationFrame(raf); }); }</script>
这里有个反直觉的点我要点出来:requestAnimationFrame那段并不是在"播放动画",它只是在每一帧把一个递增的数字写进--animation-tick。真正的绘制发生在 Worklet 里,主线程这边只负责"喂数据"。为什么不能让 Worklet 自己读时间?因为 Worklet 线程里压根没有 performance.now() 这种东西可用,它和主线程是隔离的沙箱。所以 Houdini 的交互模式是"主线程算、Worklet 画",这个分工一开始挺别扭,习惯了之后反而觉得清爽——绘制逻辑和页面逻辑彻底解耦,你改业务不会碰脏绘制代码。我刚上手时总想在 Worklet 里直接 setInterval自己动起来,折腾一晚上才发现此路不通,纯属血的教训。
跑起来之后你会发现一个很爽的点:整个网格/波浪,零额外 DOM 节点。不像我当年那样满屏 div。这也是 Paint Worklet 最大的卖点——用计算代替节点,性能好,DOM 干净。
但这里有个我必须提前吐槽的坑:Paint Worklet 画出来的东西,是一张位图。它不参与正常的 DOM 命中测试。也就是说,你画在上面的"按钮""链接",点击根本没反应,因为浏览器眼里它只是背景。需要精确交互的区域,千万别用它画。我当年就犯过这个错,花了半天想不通为什么画上去的圆点点了没反应……
三、再进阶一点:用 CSS 变量驱动,让画出来的东西"活"起来
Paint Worklet 真正好玩的地方,是它和 CSS 自定义属性(CSS 变量)是联动的。你在 inputProperties里声明了哪些变量,浏览器就会在那些变量变化时自动重绘。这意味着你改一个变量,背景就重新画一次,不需要碰一行 JS。
下面这个棋盘格背景,间距和颜色全都靠 CSS 变量控制,换主题色只改变量就行:
javascript// checkerboard-paint.js —— 可配置的棋盘格背景registerPaint('checkerboard', class { static get inputProperties() { return ['--checker-size', '--checker-color-a', '--checker-color-b']; } paint(ctx, geom, props) { // 把 CSS 变量读出来,给个兜底默认值,避免 NaN 把整个画布画崩 const size = parseInt(props.get('--checker-size').toString()) || 32; const colorA = props.get('--checker-color-a').toString() || '#f0f0f0'; const colorB = props.get('--checker-color-b').toString() || '#ffffff'; for (let y = 0; y < geom.height / size; y++) { for (let x = 0; x < geom.width / size; x++) { // 根据行列坐标的奇偶性交替填色 ctx.fillStyle = (x + y) % 2 === 0 ? colorA : colorB; ctx.fillRect(x * size, y * size, size, size); } } }});
CSS 侧这样用,注意 paint()只是背景图,变量随便改:
css.monitor-panel { --checker-size: 24; --checker-color-a: #0a3d2e; --checker-color-b: #06281f; background-image: paint(checkerboard);}
一个更接地气的例子:用 Paint Worklet 画进度环
光说棋盘格有点抽象,说个大家一定用得上的:进度环。原生 CSS 画圆环进度要么用 conic-gradient(老浏览器不支持),要么叠 SVG 算 stroke-dasharray,都不够灵活。用 Paint Worklet,传一个 --progress变量,想画多少进度画多少:
javascript// progress-ring.js —— 用 Paint Worklet 画一个圆环进度指示器registerPaint('progress-ring', class { static get inputProperties() { return ['--ring-progress', '--ring-color', '--ring-thickness']; } paint(ctx, geom, props) { const progress = parseFloat(props.get('--ring-progress').toString()) || 0; // 0~1 const color = props.get('--ring-color').toString() || '#3c67e3'; const thickness = parseFloat(props.get('--ring-thickness').toString()) || 8; const cx = geom.width / 2; const cy = geom.height / 2; const radius = Math.min(cx, cy) - thickness; // 留出线宽,避免被裁掉 // 先画一圈灰色底环 ctx.lineWidth = thickness; ctx.strokeStyle = 'rgba(0,0,0,0.08)'; ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke(); // 再按进度画彩色弧线,从 12 点方向顺时针 ctx.strokeStyle = color; ctx.beginPath(); ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + progress * Math.PI * 2); ctx.stroke(); }});
搭配 CSS 变量,进度从 0 拉到 1,环就一点点填满,连 JS 动都不用动——改个变量浏览器自己重绘。我做上传组件时就是用这招,比塞一堆 SVG 计算清爽太多。
但问题来了:如果我不是手动改变量,而是想让某个变量平滑过渡(比如一个渐变角度从 0deg 转到 360deg),直接写 transition: --my-angle 1s是不会动的。因为浏览器默认把自定义属性当成字符串,它压根不知道怎么对字符串做插值。
这时候就要请出 Houdini 里的 CSS Properties & Values API,用 CSS.registerProperty(或 CSS 里的 @property)给变量"上户口",声明它的类型:
javascript// 让一个自定义属性变成"有类型"的,这样 CSS 才能对它做平滑过渡// 没有这一步,--my-angle 只是个字符串,transition 对它无效CSS.registerProperty({ name: '--my-angle', syntax: '<angle>', // 明确告诉浏览器这是角度类型 inherits: false, initialValue: '0deg',});
配套的 CSS 就顺了:
css.badge { --my-angle: 0deg; background: linear-gradient(var(--my-angle), #5ddcff, #3c67e3, #4300c2); transition: --my-angle 1s linear;}.badge:hover { --my-angle: 360deg; }
这一步其实是 Paint 和 Animation 之间的桥梁。记住一句话:凡是你想让"非标准"的 CSS 值动起来,先给它注册类型。
四、Animation Worklet:当动画不再只听时间的
说完画,再说动。传统的 CSS 动画(transition/@keyframes)有两个我越用越烦的毛病:一是纯时间驱动,动画只跟时间走,你想让它跟着滚动走、跟着手势走,很难;二是无状态,你拿不到动画执行到一半的中间状态,更别说中途干预。
我举个真事:有次要做一个"滚动到哪、标题缩到哪"的吸顶效果,用@keyframes硬凑,结果滚动快了动画跟不上、滚动慢了又卡顿,怎么调easing都别扭——因为它的进度只认时间,不认滚动位置。后来才明白,这种"状态跟外部输入绑定"的动画,本来就不是时间轴能优雅表达的。
Animation Worklet(API 层面叫 Animation API,底层跑在 AnimationWorklet 线程)就是来补这两个短板的。它和 Paint Worklet 一样,也是"写个 Worklet + addModule 加载 + 主线程创建动画"的三步走。
下面这个例子是我们团队做滚动视差时真正用过的:用ScrollTimeline让一个元素跟着滚动距离平移,而不是跟着时间。
javascript// scroll-animator.js —— Animation Worklet,跑在独立的合成线程上registerAnimator('scroll-driven', class { // animate 的两个参数让你可以"插手"动画过程 // currentTime:驱动动画的进度值(可以是时间,也可以是滚动距离) // effect:对应主线程传进来的 KeyframeEffect // 改它的 localTime 就像在拖进度条,动画停在哪由你说了算 animate(currentTime, effect) { effect.localTime = currentTime; }});// ===== 主线程 =====// 先异步加载 Animation Worklet 模块await CSS.animationWorklet.addModule('scroll-animator.js');// 用 WorkletAnimation 把"动画定义 + 关键帧 + 时间轴"绑在一起new WorkletAnimation( 'scroll-driven', // 对应 registerAnimator 的名字 new KeyframeEffect( document.querySelector('#target'), [ { transform: 'translateX(0)' }, { transform: 'translateX(500px)' }, ], { duration: 2000, fill: 'both' } ), // ScrollTimeline:用滚动距离代替时间轴来驱动动画 // 滚到顶传 0,滚到底传 timeRange(这里是 2000),中间值按比例 new ScrollTimeline({ scrollSource: document.querySelector('.scroll-area'), orientation: 'vertical', timeRange: 2000, })).play();
effect.localTime这个东西特别好用:你不改它,动画就跟着document.timeline匀速走;你把它设成固定值(比如1000ms),动画就定格在那一帧。等于你手里多了个进度条,想停哪停哪。做滚动驱动、视差、手势联动,全靠它。
这里有个细节我当初绕了半天才懂:currentTime和effect.localTime是两码事。currentTime是"外部驱动值"——你传时间它就是时间,传滚动距离它就是滚动距离;而effect.localTime是"动画内部进度",你把它设成什么,动画就定格在那一帧。默认情况下浏览器帮你把currentTime原样赋给effect.localTime,所以平时看不出区别;一旦你自己接管animate()改写localTime,就能实现"滚到哪、动画停哪",甚至"按手势进度反向播放"这种原生 CSS 动画想都不敢想的操作。
说句掏心窝的,我第一次跑通滚动驱动动画时确实兴奋,但转头一看支持度,心又凉了半截——这玩意儿除了 Chrome 开实验 flag 基本跑不了。所以它现在对我更像"能力储备":哪天支持度上来了,我这套代码直接能用;今天真要上线,我还是老老实实用 position: sticky加IntersectionObserver那套稳的方案顶着,不拿线上用户当实验田。
不过我得泼盆冷水:Animation Worklet 的支持度比 Paint Worklet 还凄惨。Paint Worklet 好歹在 Chromium 系(Chrome、Edge、Opera)能稳定用,Animation Worklet 在写这篇文章的时候,基本还停留在 Chrome 的实验特性里,要手动开 chrome://flags/#enable-experimental-web-platform-features 才能跑。所以它在生产环境,目前更多是"技术储备"和"炫技 Demo",真要上线得配好降级方案,别拿用户当小白鼠。
说到动画的平滑切换,其实现在还有更稳的选择,比如做 SPA 路由切换时的视图过渡,用 View Transitions API 实战:给 SPA 路由切换加上丝滑的视图过渡动画 里讲的方案,浏览器原生支持度反而比 Animation Worklet 好得多。技术选型这种事,永远是哪个稳用哪个。
五、踩坑清单(人话版):Paint Worklet 不能做的事
我踩过的坑,浓缩成一份清单,省得你重蹈覆辙:
- 输出是位图,没命中测试。你画上去的"圆点""按钮"点不动,因为浏览器只把它当背景图。需要精确点击的元素,老老实实用 DOM。
- 画里的文字不可选、不可复制、不可被搜索引擎抓取。要做"可访问文字"的场景,别用它画字。
- Worklet 里发不了网络请求。它没
fetch、没XMLHttpRequest,想拿动态数据,只能先把数据算好塞进 CSS 变量再传进去。 paint()必须轻量。它可能被高频调用(变量一变就重绘),里面别做重计算、别查大数组。能预生成的查找表、随机表,在模块加载时就准备好,别在paint()里现算。inputProperties漏声明就拿不到值。这是个静默失败,变量明明设了,paint 里取出来却是空的,debug 能让你怀疑人生。- 只支持 Chromium 系。Firefox、Safari 基本不支持 Paint Worklet,更别说 Animation Worklet。所以降级样式不是"可选",是"必须"。
registerPaint重复注册同名会直接抛错,不是覆盖。调试时热更新 Worklet 文件,记得文件名或类名换一换,否则控制台红一片,你还以为自己代码写错了。- Worklet 文件必须走 HTTP(S) 或 localhost 加载——
file://直接双击打开的页面是加载不了 Worklet 的。这是我被"本地双击 HTML 看效果"这个老习惯坑过的一次,查了俩小时才发现是协议问题,至今想起都觉得丢人。
关于第 4 点怎么验证 paint()到底轻不轻量——我一般会在本地用Performance API 实战:用 Navigation/Resource Timing 做前端性能监控(Web Vitals) 里讲的 Timing API,盯着长任务的耗时和帧率,一旦发现滚动掉帧,优先排查 Worklet 里的计算复杂度。别等用户投诉了才想起来测。
另外,如果你想给那些"需要量尺寸再决定怎么画"的场景做降级或对比方案,与其手动监听offsetWidth,不如用ResizeObserver 与 MutationObserver 实战:监听元素尺寸与 DOM 变化(告别轮询)里讲的ResizeObserver 异步拿到元素尺寸——这比 Paint Worklet 的 geom更适合做"画之前先量一下"的补充逻辑。
六、浏览器支持与降级:别拿用户当小白鼠
讲真,Houdini 全家桶里,目前真正能放心上生产的,也就是 Paint Worklet + CSS Properties & Values API(@property)这两位。Animation Worklet 和 Layout Worklet 还早。
所以落地姿势我建议分四步:
第一步,确认目标浏览器。以 Chromium 为主的内网系统、管理后台,可以直接上;要全浏览器覆盖的 C 端项目,必须准备降级。
第二步,写 Worklet 时把可预生成的数据在模块加载时准备好,保证 paint()内部只做轻量运算。
第三步,主线程异步注册 Worklet,CSS 侧用 @supports做特性检测,降级样式优先。
第四步,用 CSS 自定义属性驱动主题切换,绘制结果自动响应。
我踩过的真实决策是:内部监控后台(全员 Chrome)我直接上了 Paint Worklet 做网格背景,爽了半年;但同一个组件要塞进对外的官网时,我立刻砍掉,换回 SVG pattern 加 @supports 兜底——因为官网用户里 Safari 占三成,不能赌。一句话记牢:内网可激进,C 端必保守,技术选型的底线永远是"用户的浏览器说了算"。
特性检测的代码长这样,能写多稳写多稳:
javascript// 永远先做特性检测,再谈花活if ('paintWorklet' in CSS) { CSS.paintWorklet.addModule('wave-paint.js');} else { // 降级:老浏览器就给个纯色背景,别整崩了 document.querySelector('#wave').style.backgroundColor = '#ff3e81';}
CSS 侧配合 @supports:
css@supports (background: paint(wave)) { #wave { background-image: paint(wave); }}
支持度这块,别听我嘴上嘚啵,自己查最新的。MDN 的 Houdini APIs 文档讲得全,caniuse 的 CSS Paint API 条目能看实时兼容矩阵,还有个专门盯着支持进度的网站Is Houdini ready yet?。另外 Google Chrome Labs 的 houdini-samples里有一堆官方样例,想抄作业直接去翻。
七、小结:什么时候该想起 Houdini
写到这,其实判断标准就一条:原生 CSS 表达不了、又不想堆 DOM 或贴图的视觉效果,才轮到 Paint Worklet 出场。纹理背景、复杂边框、波形/粒子这类"用 CSS 规则写不出来、用图又太重"的场景,它是天选之子:零额外节点、跑在独立线程、跟着 CSS 变量响应式重绘。
Animation Worklet 则是"动画想摆脱时间轴、想跟着滚动/手势走"时的利器,但支持度劝退,现阶段当技术储备更合适。
最后送你三句话当口诀:
- 画之前先想清楚:要不要交互?要不要文字可选?要的话,Paint Worklet 不是你的菜。
- 变量先行:所有想"动起来"的非标准值,先
registerProperty/@property上类型。 - 降级是底线:
@supports+ 特性检测双保险,永远给用户一个能用的版本。
CSS Houdini 不是什么黑魔法,它只是把浏览器渲染引擎的一扇后门,悄悄开给了愿意折腾的人。你不需要成为浏览器内核专家才能用,只要记牢它的边界——画的东西是位图、只在 Chromium 系稳、变量驱动而非时间驱动——它就是你工具箱里那把趁手的"自定义绘制"刀。下次再遇到"CSS 画不出来"的需求,别急着堆 div 了——先想想,能不能自己提笔,让它画给你看。
如果你拿不准到底要不要上 Houdini,问自己三个问题:第一,这个效果用纯 CSS 真的写不出来吗?能写出来的就别炫技,技术债迟早要还。第二,这个元素需要被点击、里面的文字需要被选中吗?只要有一个"是",Paint Worklet 直接 pass,它画出来的是位图,不参与命中测试。第三,你的用户主力用 Chrome 吗?如果一大波是 Safari / iOS,那 Paint Worklet 也得谨慎,老老实实给降级。三个问题想清楚,Houdini 就用得其所;否则多半是给自己找罪受,还顺带坑了用户的体验。
标签:css, css3, 前端描述:用真实踩坑讲清 CSS Houdini 的 Paint/Animation Worklet 怎么用:从自定义绘制背景、用 CSS 变量驱动重绘,到让动画摆脱时间轴跟滚动走,并附上浏览器支持度、特性检测与降级方案等工程落地要点。

发表评论发布评论后审核成功,即可显示在列表中...