1. 滚动体验定制这件事为什么值得单独拎出来做一套方案前端圈子里有个挺有意思的现象大家愿意花两周时间打磨一个按钮的悬浮动效却往往用一行overflow: auto就把整页滚动体验打发了。可真到了项目里产品经理一句“这个滚动能不能丝滑一点”“滚动到某个位置让导航栏变个样子”“移动端那个橡皮筋效果能不能收一收”你就得从头翻文档、拼插件、调兼容最后拼出来的东西还未必稳。我这次要聊的就是围绕Houdini Scroll Customization API这套思路把滚动体验从“浏览器默认给什么就用什么”变成“我想要什么就定义什么”。这里说的 Houdini指的是 CSS Houdini 那一整套底层扩展能力——CSS.registerProperty、CSS.paintWorklet、AnimationWorklet以及和滚动强相关的ScrollTimeline、ViewTimeline这些正在逐步落地的规范能力。把它们组合起来你就能对滚动过程中的视觉表现、动画驱动、属性变化做相当细粒度的接管。这套方案能解决什么问题简单说三类第一类是滚动驱动的动画比如视差、进度条、元素渐入渐出过去靠scroll事件加requestAnimationFrame手动算现在可以交给合成线程第二类是滚动容器的视觉定制比如自定义滚动条、滚动边缘的渐隐遮罩、滚动到边界时的反馈第三类是滚动状态的样式联动比如滚动方向变化时改变某个属性让 CSS 自己去响应。适合谁来参考如果你是有一定 CSS 和 JavaScript 基础、做过滚动相关交互、被性能问题折磨过的前端这套内容能直接拿去改如果你是对 Houdini 只闻其名没动过手的中级开发者我也会把每个环节的原理和踩坑点讲透照着做能跑起来。下面所有代码和参数都是我实际调过的思路不是纸上谈兵。2. 整体方案设计与技术选型拆解2.1 为什么是 Houdini而不是继续堆 scroll 事件先说清楚选型逻辑不然容易变成“为了用新技术而用新技术”。传统的滚动监听方案长这样监听scroll事件在回调里读scrollTop算比例再写样式。问题在于scroll事件触发频率高回调跑在主线程一旦主线程被别的任务占住动画就卡顿、掉帧用户感知就是“不跟手”。Houdini 相关能力的核心价值是把一部分工作从主线程挪到合成线程或者渲染管线的更底层。ScrollTimeline和ViewTimeline让动画的进度直接由滚动位置驱动浏览器可以在合成阶段就推进动画不需要每帧回主线程问“现在滚到哪了”。CSS.registerProperty则让自定义属性具备类型和初始值从而可以被动画系统平滑插值而不是像普通自定义属性那样只能离散跳变。我用一个生活类比传统方案像是你每走一步都要回头问教练“我走了多远”教练还得放下手里的活回答你Houdini 的方案像是给跑道本身装了刻度你跑到哪刻度自动对应到动作教练根本不用参与。这个差别在长列表、复杂背景、移动端低端机上尤其明显。当然选型也要认清现实Houdini 各能力的浏览器支持并不整齐registerProperty支持面已经比较广ScrollTimeline和ViewTimeline在主流现代浏览器的新版本里可用但老版本和部分环境仍需降级。所以我的整体设计原则是渐进增强核心滚动功能用传统方式保底增强体验用 Houdini 能力叠加检测到不支持就优雅退回。2.2 方案分层三层结构各管什么我把整套方案拆成三层这样维护和降级都清晰。层级职责关键技术降级策略基础层滚动容器行为、滚动条样式原生 CSSscrollbar-*、overscroll-behavior完全可用无需降级驱动层滚动进度驱动动画ScrollTimeline、ViewTimeline退回IntersectionObserverscroll节流表现层自定义属性插值、绘制定制CSS.registerProperty、paintWorklet退回普通 CSS 过渡或静态样式基础层几乎零风险先把滚动容器本身收拾干净overscroll-behavior: contain防止滚动穿透scrollbar-width和scrollbar-color做基础美化scroll-snap-type处理分页式滚动。这一层不依赖任何新能力是所有项目的起点。驱动层是体验的核心。用ScrollTimeline把某个动画的进度绑定到滚动容器的滚动范围上写法上就是给animation-timeline指定一个 timeline 对象。ViewTimeline更进一步绑定的是某个元素在视口中的可见进度做“元素进入视口时渐入”这类效果特别顺手。表现层负责“好看”。registerProperty注册带类型的自定义属性让它们能参与动画插值paintWorklet则可以在滚动容器的边缘画渐隐遮罩、画自定义滚动指示器。这一层最出彩但也最需要做好降级因为paintWorklet的支持面相对窄一些。2.3 性能账怎么算合成线程到底省了什么很多人对“放到合成线程”没概念我补一个粗略的账。假设页面滚动 60fps持续 3 秒就是 180 帧。传统方案每帧主线程要执行一次滚动回调回调里读布局属性scrollTop本身还好但如果读了offsetHeight就会触发强制同步布局再写样式写样式又可能触发重排重绘。一次回调哪怕只花 2ms180 帧就是 360ms 的主线程占用这期间任何其他交互都得排队。用ScrollTimeline驱动动画主线程在滚动过程中基本不参与动画进度由浏览器内部推进主线程占用接近 0。这就是为什么同样的视觉效果换驱动方式后“跟手感”完全不同。当然前提是动画属性本身是合成友好的比如transform、opacity如果你动画的是width、top这类触发布局的属性那省下来的主线程时间又会被布局吃回去。所以我在方案里坚持一条滚动驱动的动画只碰合成友好属性这条后面还会反复强调。3. 核心细节解析与实操要点3.1 registerProperty让自定义属性“能被动画”普通自定义属性有个坑--progress: 0到--progress: 1浏览器不知道这是数字还是字符串所以过渡时只能离散跳变没有中间值。CSS.registerProperty就是给它上户口声明类型、初始值、是否继承。CSS.registerProperty({ name: --scroll-progress, syntax: number, inherits: false, initialValue: 0 });这段代码在 JS 里执行一次即可之后--scroll-progress就是一个可插值的数字属性。syntax支持number、length、percentage、color等选错类型会导致注册失败或者行为异常。inherits: false是我在滚动场景里的常用选择因为进度值通常只作用于特定元素继承下去反而容易污染子元素。实操要点注册要在使用之前完成最好放在应用初始化阶段。如果属性名拼错注册不会报错但也不生效排查时先确认getComputedStyle里能不能读到注册后的类型行为。另外注册过的属性在 DevTools 的 Computed 面板里会显示类型信息这是快速验证的手段。3.2 ScrollTimeline 与 ViewTimeline两种驱动模型别混用ScrollTimeline的进度对应整个滚动容器的滚动范围从顶部滚到底部是 0 到 1。适合做全局进度条、背景视差这类“跟整页滚动挂钩”的效果。const timeline new ScrollTimeline({ source: document.querySelector(.scroll-container), axis: block });axis: block表示跟随块轴方向垂直滚动就是它水平滚动容器用inline。source必须是实际的滚动容器如果传了非滚动元素timeline 的进度会一直是 0这是新手最常见的错误之一。ViewTimeline的进度对应某个元素在视口中的可见过程有cover、contain、entry、exit几个范围概念。做“卡片进入视口时从下方滑入并渐显”用ViewTimeline比ScrollTimeline精准得多因为它的 0 和 1 天然对齐元素的进入和离开。const viewTimeline new ViewTimeline({ subject: document.querySelector(.card), axis: block });两者的选择逻辑效果跟“整页滚到哪”相关就用ScrollTimeline跟“某个元素露出来多少”相关就用ViewTimeline。混用的典型错误是拿ScrollTimeline去做元素级渐入结果元素在页面不同位置时进度对不上调半天调不准。3.3 动画绑定animation-timeline 的正确姿势有了 timeline绑定到动画上有两种写法。CSS 里直接写.progress-bar { animation: grow linear; animation-timeline: --page-scroll; animation-range: 0% 100%; } keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }这里--page-scroll需要在 CSS 里用scroll-timeline-name声明或者在 JS 里通过element.style.animationTimeline赋值。animation-range控制动画在 timeline 的哪个区间内生效做“滚到一半才开始动”的效果就靠它。JS 写法更灵活const anim element.animate( [{ transform: scaleX(0) }, { transform: scaleX(1) }], { duration: 1, fill: both } ); anim.timeline timeline;注意duration在 timeline 驱动下语义变了它不再是毫秒时间而是 timeline 进度的映射比例通常写 1 配合fill: both。这个细节文档里写得绕实测下来就是“duration 设 1进度 0 到 1 对应动画从头到尾”。3.4 paintWorklet滚动边缘的渐隐遮罩怎么画滚动容器内容溢出时边缘硬切很难看。用paintWorklet画一个渐隐遮罩视觉上会柔和很多。核心是注册一个 paint 模块// mask-painter.js registerPaint(scroll-fade, class { static get inputProperties() { return [--fade-height, --fade-color]; } paint(ctx, size, props) { const h parseFloat(props.get(--fade-height).toString()) || 24; const gradient ctx.createLinearGradient(0, 0, 0, h); gradient.addColorStop(0, rgba(0,0,0,1)); gradient.addColorStop(1, rgba(0,0,0,0)); ctx.fillStyle gradient; ctx.fillRect(0, 0, size.width, h); } });然后在主线程CSS.paintWorklet.addModule(mask-painter.js)元素上写background: paint(scroll-fade)。这里画的是顶部渐隐底部同理翻转渐变方向。注意事项paintWorklet运行在独立的渲染上下文不能访问 DOM所有输入必须通过inputProperties传进去。props.get()返回的是 CSSStyleValue取数值要parseFloat转换。另外 paint 里别做重计算它可能每帧调用画个渐变就够了别在里面遍历大数组。3.5 滚动条与滚动行为的底层收拾再花哨的驱动基础没收拾好也白搭。滚动条定制用标准属性.scroll-container { scrollbar-width: thin; scrollbar-color: #888 transparent; overscroll-behavior: contain; scroll-behavior: smooth; }overscroll-behavior: contain防止滚动到边界后把滚动传给父容器移动端尤其重要不然会出现“滚到底整页跟着弹”的尴尬。scroll-behavior: smooth让锚点跳转平滑但注意它和 JS 的scrollTo({ behavior: smooth })会叠加别重复设置导致行为怪异。Webkit 系还可以用::-webkit-scrollbar系列伪元素做更细的定制但这套是非标准和标准属性混用时要注意优先级和覆盖关系。我的做法是标准属性打底Webkit 伪元素做增强两者不冲突的部分各自生效。4. 完整实操流程与关键环节实现4.1 环境准备与能力检测动手前先做能力检测决定走增强路径还是降级路径。检测逻辑我封装成一个函数function detectScrollCapabilities() { return { registerProperty: typeof CSS ! undefined registerProperty in CSS, scrollTimeline: typeof ScrollTimeline ! undefined, viewTimeline: typeof ViewTimeline ! undefined, paintWorklet: typeof CSS ! undefined paintWorklet in CSS }; }检测结果决定后续分支。registerProperty不支持时自定义属性退回离散值动画用普通过渡凑合ScrollTimeline不支持时退回IntersectionObserver加节流scroll监听paintWorklet不支持时渐隐遮罩用mask-image加线性渐变静态实现效果差一点但能用。提示能力检测要在应用初始化时做一次并缓存结果别在滚动回调里反复检测那是纯浪费。4.2 第一步搭好滚动容器骨架容器结构建议是“外层固定高度 内层内容”div classscroll-container div classscroll-content !-- 内容 -- /div /div.scroll-container { height: 100vh; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #999 transparent; position: relative; }height: 100vh是常见选择但移动端要注意地址栏收起展开导致的视口变化可以用100dvh更稳。position: relative是为了后续绝对定位的进度条、遮罩层有参照。4.3 第二步注册属性并建立 timeline初始化阶段按顺序做三件事注册属性、创建 timeline、绑定动画。// 1. 注册可插值属性 CSS.registerProperty({ name: --scroll-progress, syntax: number, inherits: false, initialValue: 0 }); // 2. 创建滚动 timeline const container document.querySelector(.scroll-container); const scrollTimeline new ScrollTimeline({ source: container, axis: block }); // 3. 绑定进度条动画 const bar document.querySelector(.progress-bar); bar.animate( [ { transform: scaleX(0) }, { transform: scaleX(1) } ], { duration: 1, fill: both, timeline: scrollTimeline } );这段跑通后滚动容器时进度条就会跟着走。如果不动先查source是不是真的滚动容器再查bar是不是在容器内且可见。我踩过一次坑进度条放在容器外面transform-origin又没设结果动画在跑但视觉上看不出来排查了半天才发现是定位问题。4.4 第三步元素级渐入用 ViewTimeline卡片渐入是高频需求用ViewTimeline实现document.querySelectorAll(.card).forEach(card { const vt new ViewTimeline({ subject: card, axis: block }); card.animate( [ { opacity: 0, transform: translateY(40px) }, { opacity: 1, transform: translateY(0) } ], { duration: 1, fill: both, timeline: vt, rangeStart: entry 0%, rangeEnd: entry 100% } ); });rangeStart和rangeEnd用entry范围表示元素从开始进入到完全进入这段区间内完成动画。如果想让动画在元素完全进入后才开始改成cover范围配合百分比。参数选择的核心是“你想让动画对齐元素可见过程的哪一段”这个想清楚了参数就定了。4.5 第四步滚动方向感知与样式联动滚动方向变化时切换某个状态过去要手动比较前后scrollTop。现在可以用registerProperty加一个方向属性配合滚动监听写入let lastTop 0; container.addEventListener(scroll, () { const top container.scrollTop; const dir top lastTop ? 1 : -1; container.style.setProperty(--scroll-direction, dir); lastTop top; }, { passive: true });配合注册CSS.registerProperty({ name: --scroll-direction, syntax: number, inherits: true, initialValue: 1 });然后 CSS 里可以根据方向做不同过渡比如导航栏下滑隐藏、上滑显示。这里passive: true很重要告诉浏览器这个监听不会阻止默认滚动能减少滚动延迟。方向判断加个阈值更稳避免微小抖动导致状态反复切换比如Math.abs(top - lastTop) 4才更新。4.6 第五步边缘渐隐遮罩接入把前面写的 paint 模块接入if (capabilities.paintWorklet) { CSS.paintWorklet.addModule(/worklets/mask-painter.js); }.scroll-container::before { content: ; position: absolute; top: 0; left: 0; right: 0; height: 32px; background: paint(scroll-fade); pointer-events: none; z-index: 2; }pointer-events: none必须加否则遮罩会挡住内容点击。z-index要高于内容但低于交互层具体数值看你的层级规划。渐隐高度通过--fade-height传入我一般设 24 到 40 之间太窄看不出效果太宽会遮内容。4.7 参数速查与调优建议参数常用值作用调优建议axisblock / inline滚动方向轴垂直滚动用 block水平用 inlineduration1timeline 映射比例固定 1靠 range 控制区间rangeStartentry 0% / cover 0%动画起点元素级用 entry全局用 coverrangeEndentry 100% / cover 100%动画终点与起点范围保持一致--fade-height24–40px渐隐遮罩高度按容器视觉密度调方向阈值4px方向切换灵敏度移动端可加大到 8px这张表是我反复调出来的经验值不是规范里的固定值你可以按项目视觉风格微调但范围逻辑别乱。5. 常见问题与排查技巧实录5.1 动画不跟滚动走进度一直是 0这是最高频的问题原因基本集中在三处。第一source传错传了外层非滚动元素timeline 找不到滚动范围进度恒为 0。第二容器没有实际溢出内容高度不够滚动范围是 0自然没有进度。第三动画元素的animation-timeline被其他样式覆盖比如某个重置样式把animation简写覆盖了。排查顺序先在 DevTools 里确认容器scrollHeight clientHeight再确认 timeline 的source引用正确最后检查计算样式里animation-timeline的实际值。我习惯在初始化后打一行日志输出timeline.source container一眼就能看出引用对不对。5.2 滚动时依然卡顿没感觉到性能提升先别怀疑 Houdini先看动画属性。如果你动画的是width、height、top、margin这类触发布局的属性那不管用什么驱动都会卡因为布局开销跑不掉。改成transform和opacity再测。其次看有没有在滚动回调里做重活。即使主驱动交给了 timeline如果你还挂着scroll监听做复杂计算主线程照样忙。把非必要的滚动监听去掉或者用requestIdleCallback挪到空闲时段。还有一种情况是容器内元素太多滚动本身的重绘开销大。这时候要考虑虚拟列表或者content-visibility: auto让视口外内容跳过渲染。这个和 Houdini 无关但经常被混为一谈。5.3 paintWorklet 注册失败或绘制空白addModule的路径是相对当前文档的 URL不是相对 JS 文件写相对路径容易 404。用绝对路径或者基于import.meta.url拼。另外 worklet 文件里不能有import之外的模块依赖也不能访问window、document写了就报错。绘制空白还有一个原因inputProperties里声明的属性没在元素上设置props.get()返回空parseFloat得到NaN绘制逻辑直接跳过。给个兜底默认值比如|| 24能避免大部分空白问题。5.4 移动端滚动穿透和橡皮筋overscroll-behavior: contain能解决大部分滚动穿透但 iOS 的橡皮筋效果需要额外处理。容器上同时设overscroll-behavior-y: contain弹层打开时给body加overflow: hidden并记录scrollTop关闭时恢复。这套组合拳实测下来在主流移动浏览器上表现稳定。注意给body设overflow: hidden会导致页面跳动因为滚动条消失引起宽度变化。补偿方案是给body加padding-right等于滚动条宽度或者用scrollbar-gutter: stable预留空间。5.5 常见问题速查表现象可能原因排查动作解决进度恒为 0source 非滚动容器检查引用传正确容器动画不触发容器无溢出看 scrollHeight补内容或调高度滚动卡顿动画布局属性看动画属性改 transform/opacity遮罩空白属性未设置看 inputProperties加默认值移动端穿透缺 overscroll检查样式加 contain方向抖动阈值太小看监听逻辑加 4–8px 阈值5.6 几个文档里不会写的避坑心得第一个心得timeline 对象要复用别每次滚动重建。有人图省事在回调里new ScrollTimeline结果内存飙升、GC 频繁。timeline 是长期对象初始化建一次绑定到多个动画上复用。第二个心得降级路径要真测别只写不测。我见过项目里写了降级分支但从没在旧环境跑过上线后旧设备直接白屏。降级逻辑至少要在关闭对应能力的条件下手动验证一遍确保核心功能可用。第三个心得动画的fill: both别漏。漏了之后动画结束瞬间会跳回初始状态滚动回顶部时元素突然消失视觉上很突兀。fill: both让动画在 timeline 两端都保持终态过渡自然。第四个心得进度条这类全局元素transform-origin要显式设。默认是中心scaleX会从中间向两边扩看起来像“从中间长出来”而进度条通常要“从左往右长”得设transform-origin: left center。这个细节不设也能跑但视觉不对容易被忽略。6. 方案延展与组合玩法6.1 视差背景的轻量实现视差效果过去靠监听滚动改translateY现在用ScrollTimeline驱动背景层的transform主线程零参与。关键是把背景层和内容层分开背景层transform: translateY的幅度小于内容层形成速度差。幅度比例我一般控制在 0.2 到 0.4 之间太大容易晕太小看不出视差。bgLayer.animate( [ { transform: translateY(0) }, { transform: translateY(-15%) } ], { duration: 1, fill: both, timeline: scrollTimeline } );-15%是相对背景层自身高度配合容器overflow: hidden裁切效果干净。注意背景层要比容器高出一截不然位移后底部露白。6.2 滚动进度与主题色联动用registerProperty注册一个颜色属性滚动进度映射到色相变化做“滚动变色”的氛围效果。这个玩法在品牌页、活动页里很讨喜。实现上把进度值通过calc映射到hsl的色相通道或者直接在 JS 里根据进度写--theme-hue。前者更纯粹后者更可控看你对精度的要求。6.3 分页式滚动的吸附与反馈scroll-snap-type: y mandatory配合scroll-snap-align: start做整屏吸附再叠加ViewTimeline做每屏进入时的内容渐入就是一套完整的“幻灯片式滚动”体验。吸附本身是原生能力稳定可靠渐入用 timeline 驱动性能好。两者组合的关键是吸附容器和 timeline 的source要指向同一个元素否则进度对不上。6.4 什么时候该收手不要过度定制的边界最后说点反向经验。滚动体验定制有个度超过这个度就是给自己挖坑。第一不要为了炫技把原生滚动行为改得面目全非用户对滚动的肌肉记忆很强改太多反而难用。第二不要在所有页面都上重动画内容型页面滚动要的是“快和稳”不是“花”。第三降级路径的成本要提前算如果某个效果在不支持的环境下体验断崖式下跌那这个效果就不该作为核心依赖。我个人的判断标准很简单关掉所有增强效果核心内容还能不能顺畅阅读和操作。能就放心加不能就先补基础。滚动这件事稳永远比炫重要Houdini 给的是更稳地实现炫的能力而不是炫本身的理由。
阅读完成 · 觉得有帮助?