1. 为什么说滚动动画长期是前端的一块硬骨头
先交代一下背景。我做了七八年前端,以前提到"页面随滚动产生动画",第一反应就是监听scroll事件,配合requestAnimationFrame手动计算滚动位置,再通过 JS 改元素的transform或opacity。这方案能跑,但说实话,麻烦事一箩筐。
最直接的问题是性能。滚动事件在移动端和低端桌面设备上触发频率极高,哪怕你用requestAnimationFrame做了节流,主线程的布局和样式计算压力始终不小。另一个麻烦是逻辑分散:一段滚动渐入动画,要在 JS 里写滚动监听函数、计算元素进入视口的 offset、处理元素离开视口的反向恢复,代码量一多,维护起来特别容易出 bug。
还有一个容易被忽略的问题:滚动动画和 CSS 原生动画天生是割裂的。你在 CSS 里定义好的@keyframes,被迫通过 JS 去控制进度,中间村隔了一层"翻译"。这导致团队里经常出现一种情况——设计师说"我要这里滑过来的时候转个角度",前端要先量尺寸、算偏移、写函数,折腾半天效果还差强人意。
所以当 Chrome 在 115 版本正式全面支持 CSS 滚动驱动动画(Scroll-driven Animations)的时候,我是很兴奋的。这个特性让动画可以直接"绑"在滚动进度上,滚动多少,动画就走多少,不需要监听任何事件,不需要 JS 计算位置,纯粹靠 CSS 就能让页面跟着手的动作"起舞"。
这篇文章不是翻译文档,也不是抄 MDN 的 API 说明。我会按照自己实际项目里用下来的经验,把它的核心机制、常用搭配、兼容性处理和踩过的坑一条一条讲清楚。如果你过去也写过类似"滚动到某个位置元素渐入"这类交互,看完应该能直接用来替代掉一大部分老代码。
2. 先搞明白时间线:为什么动画知道当前"走到哪一步"
滚动驱动动画的底层逻辑,是把滚动进度变成一条"时间线"。普通 CSS 动画的时间线是页面加载后的时钟时间,跑完一遍拉倒;滚动驱动动画的时间线则是滚动容器的滚动距离,手指拖多少,动画就播放多少,往前滚就是正向播放,往回滚就是反向倒退。
2.1scroll()函数:绑定滚动容器的进度
scroll()函数的写法很简单:
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } } .progress-bar { animation: grow linear; animation-timeline: scroll(); }这里animation-timeline: scroll()告诉浏览器:这个动画的时间线来自最近的滚动容器(默认是视口)。关键点是linear,因为时间线已经不由时钟控制,你再用ease-in之类的缓动函数,会在"滚动进度"和"动画进度"之间又插一层非线性映射,手感和直觉就对不上了。
scroll()函数也可以接受参数,比如:
animation-timeline: scroll(block); animation-timeline: scroll(inline);block代表纵向滚动,inline代表横向滚动。还有一个root关键字可以强制指定视口根滚动容器,防止被外层滚动容器影响。大多数场景下你直接写scroll()就行,默认值就是纵向滚动。
用生活化的比喻来说:scroll()就像你用手指顺着进度条往下划,进度条的位置直接控制你家影碟机的播放头。进度条到头,影片正好放完。
2.2view()函数:让元素自己"露脸"时开始
scroll()解决的是整个滚动容器的进度,但日常需求里更常见的是这种:某个元素从视口底部露头,到完全进入视口,这个过程里做动画。这就轮到view()了。
view()时间线的起点,是元素在滚动容器中即将露出视口的那一刻;终点是元素即将完全滚出视口的那一刻。说直白点,它跟踪的不是"滚动了多少",而是"元素本身在视口里出现了多少"。
@keyframes fade-in { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: none; } } .reveal { animation: fade-in ease-in; animation-timeline: view(); }这个组合已经能复现很多网站常见的"滚动渐入"效果,而且没有任何 JS 监听,浏览器原生处理,丝滑程度超过以前大多数"滚动出现"类插件的体验。
2.3 两种时间线,一个表格分清
我见过不少同学用了一会儿还是很迷糊,把两个函数混着用。这里直接做一个对照,你收藏起来以后查。
| 特性 | scroll() | view() |
|---|---|---|
| 跟踪对象 | 滚动容器的滚动进度 | 元素自身在视口内的可见度 |
| 默认起点 | 滚动位置 0 | 元素即将进入视口的瞬间 |
| 默认终点 | 滚动到最底部 | 元素完全离开视口的瞬间 |
| 典型应用 | 页面阅读进度条、视差位移 | 元素渐入渐出、卡片入场 |
| 是否依赖元素位置 | 否 | 是 |
能否通过animation-range调整 | 能,但起点受滚动容器限制 | 能,自由度更高 |
一句话记住:scroll()看整体,view()看个体。你要做全页面的滚动进度条,选scroll();你要让一堆卡片在滚动过程里逐个显示,选view()更自然。
2.4 时间线的关键细节:线性映射关系
不管用哪个函数,牢记一个原则:时间线的进度永远和滚动进度保持线性同步。CSS 里你的动画时长属性animation-duration在这种模式下不再表示"多少秒",而是变成一个比例基准值。
比如你把animation-duration设为1ms,然后写animation-timeline: scroll(),你会发现动画行为没有区别——因为时间长短根本不参与计算,它只是作为 animation 简写里一个必须存在的字段。
真正参与计算的是滚动距离和animation-range的区间映射。这个机制越早想明白,后面写复杂动画越不容易猜错。
3.animation-range:把动画严格锁定在你想要的滚动区间
animation-timeline定义了"驱动动画的进度源",但默认状态下动画播得太散。比如view()的默认区间是整个元素可见过程,从元素刚露头到最后滚出屏幕。如果你只想在"元素进入视口的这段距离"里播放动画,后半段让它定格,就需要animation-range来划范围。
3.1 用百分比切分进度区间
animation-range的取值语法大致是这样:
.reveal { animation-timeline: view(); animation-range: entry 0% entry 100%; }这里entry表示元素进入视口的阶段。entry 0%是元素刚触碰视口下边缘的时候,entry 100%是元素完全进入视口的时候。后面我会专门解释这套命名关键词。
如果你只想让动画发生在"元素从下往上露脸,露到一半"这段距离内:
.reveal { animation-timeline: view(); animation-range: entry 0% entry 50%; }这在视觉上会形成一种"快速开始、提前结束"的效果,后面元素继续往上走,动画却已经定格在最终状态。
除了entry,还有exit、cover、contain这些关键词。
entry:元素进入视口的阶段,范围从刚开始露头到完全进入exit:元素离开视口的阶段,范围从开始要滚出到完全滚出cover:元素从开始进入视口到完全离开视口,默认的完整过程contain:元素完全在视口内部的那个阶段
理解这些词的最好方式是想象一个透明视口矩形和一个元素矩形。元素移动过程中,被视口"剪裁"的状态就是时间线状态。
举一个具体例子。你要做一个"图片从底部滑上来,走到正中央停顿一下,然后继续滑出顶部"的完整运镜:
.image-travel { animation-timeline: view(); animation-range: cover 0% cover 100%; }cover 0%对应元素开始在视口底部出现,cover 100%对应元素完全从上端离开。整个滚动过程中动画执行一遍。
如果你要的是"进入时播放进场动画,离开时不播放出场":
.image-travel { animation-timeline: view(); animation-range: entry 0% entry 100%; }3.2 用长度值精确定位触发点
百分比写法方便,但它完全依赖视口高度和元素高度的比例。有些场景你需要"永贴底部 200px 处触发"这种精确控制,就可以用长度值:
.reveal { animation-timeline: view(); animation-range: entry 0px entry 200px; }这个写法表达的意思是:元素在视口中从 0 到 200px 的位移区间内完成动画。相比百分比,长度值更贴近"设计稿上的滚动距离"直觉。
这里有一个非常实用的技巧:如果你希望元素在滚动 600px 后才开始动画(比如首屏内容下方 600px 处),不要用animation-timeline: scroll()配合默认范围,因为这样动画会从滚动一开始就计时,导致你看到的元素动画进度和滚动位置不匹配。正确做法是同时设置animation-range:
.delay-reveal { animation-timeline: scroll(); animation-range: 600px 900px; }意思是:滚动容器滚过 600px 到 900px 的这 300px 距离内,完成整个动画。这种写法在制作长页面的章节过渡时极其好用。
3.3 命名时间线:滚动进度条的完整例子
进度条是滚动驱动动画最经典的场景。配合scroll()可以做到几行代码就实现一个阅读进度条:
@keyframes scale-x { from { transform: scaleX(0); } to { transform: scaleX(1); } } .read-progress { position: fixed; top: 0; left: 0; width: 100%; height: 3px; background: #1989fa; transform-origin: left center; animation: scale-x linear; animation-timeline: scroll(); }这段代码我实测下来,在 Chrome 和 Edge 上跑得非常顺,完全没有 JS 解算的延迟感。而且因为动画状态完全由滚动位置驱动,页面刷新后如果你停在中途的滚动位置,进度条也会停在中途,不需要额外代码同步。
这里再补充一个scroll-timeline的用法。有些场景整个页面不是滚动容器,但某个内部容器才是滚动区域,你希望把动画绑到那个容器上而不是视口上,可以用命名时间线:
.scroll-area { overflow-y: scroll; scroll-timeline: --scroll-timeline; } .progress { animation-timeline: --scroll-timeline; }这种代码结构在组件化开发里非常有用,你可以在父组件里定义时间线,在任意嵌套组件里引用,不需要跨层传递状态。
4. 用view-timeline让子元素监听父容器的滚动节奏
刚才讲的view()函数有个特点,它默认绑定最近的滚动祖先,且只能观察元素自身与视口的关系。但实际布局里,经常碰到一个情况:元素在某个独立滚动容器内部,比如一个横向卡片轮播、一个聊天记录面板,你想要的效果是"用户滚动这个容器时,容器里的方向指示箭头出现/消失"。
这时候直接在子元素上用view(),时间线默认可能就不是你想要的那个容器。所以需要用到view-timeline来显式声明。
4.1 声明一个具名 view timeline
.chat-panel { overflow-y: scroll; view-timeline: --panel-enter; } .chat-panel .bottom-indicator { animation-timeline: --panel-enter; animation-range: entry 0% exit 100%; }view-timeline: --panel-enter会在chat-panel这个滚动容器的自身子元素进入视口时触发时间线。子元素里的方向指示箭头可以用这个时间线做显示和隐藏。
这个能力的意义在于,它把"滚动容器的可视区域"抽象成一个统一的进度源。你不需要在 JS 里计算什么scrollTop + clientHeight > scrollHeight - 50这种高度判断来做"显示返回底部按钮",纯 CSS 就搞定了。
4.2timeline-scope:跨元素引用时间线
如果时间线声明在容器 A,而动画元素在容器 B,跨组件场景下需要timeline-scope把时间线名称提升到公共作用域。
:root { timeline-scope: --global-scroll; } .page-scroller { scroll-timeline: --global-scroll; } .anywhere-else { animation-timeline: --global-scroll; }这个特性在项目复杂起来后尤其有用,特别是你在做整体页面滚动驱动时,不同组件里的元素都需要共享同一根时间线。以前你可能会给这些元素分别绑scroll(),但如果它们各自距离视口顶部的距离不一样,起步位置会千奇百怪。通过共享时间线,你可以用animation-range单独调节每个元素在整个页面滚动中的动画窗口,协调性大大提升。
举个例子。一个页面有 3 个章节,你想让它们依次渐入,视觉节奏统一。声明了全局时间线后,第一章节的动画范围是0px 300px,第二章节是300px 600px,第三章节是600px 900px。这个映射关系是一目了然的。
5. 实战一:滚动驱动视差滚动的正确姿势
视差滚动是滚动驱动动画里最能出效果的一个方向。传统做法是监听滚动位置,然后修改background-position或者transform: translateY。用 CSS 滚动驱动动画来做,代码量少一半,而且手感更扎实。
5.1 多层背景视差
假设你有一个背景层、一个前景层,希望它们滚动速度不同,形成深度感:
@keyframes bg-move { from { transform: translateY(0); } to { transform: translateY(-200px); } } @keyframes fg-move { from { transform: translateY(0); } to { transform: translateY(-600px); } } .background-layer { animation: bg-move linear; animation-timeline: scroll(); } .foreground-layer { animation: fg-move linear; animation-timeline: scroll(); }注意我特意没有用 100vh 之类的间距配合,而是直接设一个终点位移。因为滚动驱动的 timing 天然和滚动距离挂钩,你只需要保证"目标层的位移距离和滚动距离不成 1:1",自然就出现视差效果。
如果你希望某层完全固定(视差里最常见的"钉住"效果),直接设置animation-range只有一个极小区间,或干脆让它不做位移就行。
5.2 卡片视差进场
元素列表逐个滑入是另一个高频视觉需求。如果你给每个卡片都单独写一个view()绑定,性能没问题,但节奏比较难统一。这里推荐的做法还是用全局时间线。
.card { animation-timeline: --global-scroll; animation-range: entry 0% entry 100%; } .card:nth-child(2) { animation-range: 300px 600px; } .card:nth-child(3) { animation-range: 500px 800px; }这样每张卡片从视口底部升到顶部区域的过程中各自完成位移和透明度变化,形成一种连续递进感。你可以自行调节每个卡片的 offset,让它们在页面滚动中呈波浪式交替出现,不需要任何 JS。
5.3 关于"手感"的一个小建议
用view()做卡片进场时,我建议animation-timeline用view(),而不要自己手动算scroll()加animation-range。
原因是view()的进度计算天然包含了"元素自身高度 + 视口高度"的相对关系,在不同屏幕尺寸和不同元素高度下都能自动保持"元素露头时开始、完全入画时结束"。如果你用scroll(),一旦卡片高度变化,所有 range 都要跟着调整,维护成本很高。
6. 实战二:滚动轮播与横向时间轴
有了滚动驱动动画,很多以前要靠 JS 插件实现的"滚动驱动的横滑/轮播"可以做得更轻。
6.1 横向卡片轮播
思路是:外层容器纵向滚动,内部纵向排列的卡片组,通过animation-range控制一个横向位移动画,让卡片看起来像在横向轮播。
.cards-track { display: flex; gap: 20px; animation: slide-x linear; animation-timeline: --global-scroll; animation-range: 0px 800px; } @keyframes slide-x { from { transform: translateX(0); } to { transform: translateX(calc(-100% + 100vw - 40px)); } }这段代码的意思非常明确:用户在 0 到 800px 的滚动区间内,卡片轨道从初始位置向左平移,直到最后一张卡片贴近视口右侧。你不用 Media Query,不用 JS resize,视觉上就像一列横滑卡片被"滚"了出来。
这个模式尤其适合做"时间轴""产品特性横滑区""照片墙滚动浏览"。
6.2 滚动时间轴刻度线
如果你要展示一条纵向时间轴,圆点沿着轴线依次点亮,其实只需要两个动画:
- 轴线本身:高度从 0 长到全长,绑定
view()或全局scroll() - 刻度圆点:透明度/缩放依次触发
.timeline-line { height: 100%; transform-origin: top; animation: grow-y linear; animation-timeline: --global-scroll; animation-range: 0px 1200px; } .timeline-dot { animation: pop linear; animation-timeline: --global-scroll; animation-range: 200px 260px; } .timeline-dot:nth-child(2) { animation-range: 400px 460px; }圆点会在各自对应的滚动区间内完成"弹跳出现"。配合prefers-reduced-motion和@supports,效果稳得一批。
6.3 一个千万别踩的坑:横向容器的内边距
做横滑卡片时,我犯过一个错误。卡片轨道用了padding: 0 20px,然后translateX移动时,最右侧会多出一块空白,看起来最后一张卡片没有完全对齐。
问题出在transform: translateX(calc(-100% + 100vw))这个计算里。你要把左右 padding 都算进去,正确写法是:
to { transform: translateX(calc(-100% + 100vw - 40px)); }这里的 40px 是两个 20px padding 的总和。不同项目 padding 值不同,别直接抄,按自己设计稿调常数。
7. 滚动驱动动画的边界条件和细节控制
7.1animation-duration到底还重不重要
很多同学一开始很纠结 duration。在滚动驱动动画里,duration 依然可以设置,但它不再代表"秒",而是参与动画区间和进度换算的比例。多数情况下直接给linear就可以。
但有一种情况 duration 会产生影响:当你通过animation-shorthand写全部属性时,duration 是必填项。如果你漏写,整个 shorthand 会失效。所以我的习惯是:
animation: fade-in linear both;只要写了linear和动画名,浏览器会补一个默认 duration,滚动驱动模式下并不会造成行为异常。但如果你在同一个元素上同时做"时钟时间动画"和"滚动驱动动画",那就要小心冲突,分开写更好。
7.2animation-fill-mode在滚动驱动下的作用
animation-fill-mode: both和滚动驱动动画合用,效果跟普通动画不完全一样。普通动画结束后,both会让元素停留在最后一个关键帧;滚动驱动动画里,元素状态本身就由滚动位置决定,"填充"语义会被弱化,但写both依然能保证在区间之前的初始状态是 from 关键帧。
我建议在绝大多数场景都写上both。如果不写,当滚动位置还在动画区间之前时,元素会保持默认样式,可能突然出现在页面里造成闪烁。
7.3 交互混合:和:hover等伪类共存
CSS 动画和伪类是可以共存的。比如一个卡片滚动进入视口时渐显,鼠标悬停时放大:
.reveal-card { animation: fade-up linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; transition: transform 0.3s; } .reveal-card:hover { transform: scale(1.03); }这里用transition做 hover 放大,而不是另一个animation,因为滚动驱动的动画会覆盖transform,如果你又写一个 animation 专门做 hover,两个动画的优先级纠缠会让卡片要么抖、要么放大不了。用transition处理交互态最简单干净。
还有一个经验:不要给同一个属性同时设置滚动驱动动画和普通动画。浏览器会按照"动画优先级"选一个生效,另一个被丢弃,但这个行为在不同浏览器里可能有细微差异,规避风险的方式就是属性隔离。
7.4 多段连续动画的编排
如果你希望一个元素先缩放、再位移、再变色,滚动驱动动画同样支持多 keyframe:
@keyframes multi-stage { 0% { opacity: 0; transform: scale(0.6); } 40% { opacity: 1; transform: scale(1.1); } 70% { transform: translateY(-30px); } 100% { opacity: 1; transform: translateY(-30px) scale(1); } }配合animation-range使用,可以让整个动画发生在特定的滚动区间。关键帧的百分比是"动画自身的进度",不是滚动距离。所以严格说,还是"滚动到某个位置,对应动画播放到某个百分比"这个思路。
8. 兼容性排查与优雅降级方案
可以这么说:这个特性在 2025 年的今天,已经不再是"实验功能"了。Chrome 115+、Edge 115+ 全面支持,Safari 也在较新版本里逐步跟进,Firefox 的实现状态需要关注 Can I Use。但实际项目要上线,你不能赌用户一定用了最新版本浏览器,必须有降级方案。
8.1 用@supports判断是否支持
最优雅的降级方式就是@supports:
.reveal { opacity: 0; transform: translateY(40px); transition: opacity 0.6s, transform 0.6s; } @supports (animation-timeline: scroll()) { .reveal { animation: fade-up linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; transition: none; } }这个写法逻辑很清楚:老浏览器走 transition,新浏览器走滚动驱动动画。在支持的浏览器里,transition: none让动画完全由滚动驱动;不支持的浏览器里,元素会有一个基础的过渡效果(虽然不能感知滚动,但至少不会是一个死板板的正立元素,直接呼地出现)。
但这里有个细节:老浏览器里.reveal初始opacity: 0,如果 JS 没有做任何处理,元素可能永远看不见。稳妥的方案是配合@media (prefers-reduced-motion: reduce)或者在没有支持的浏览器里干脆让元素保持可见。
8.2 用 JS 做渐进增强检测
我项目里直接向量化处理:
const supportsTimeline = CSS.supports('animation-timeline', 'scroll()'); if (!supportsTimeline) { document.body.classList.add('no-scroll-animation'); }然后在 CSS 里:
.no-scroll-animation .reveal { opacity: 1; transform: none; }这个方案的优势是不依赖正反逻辑嵌套。no-scroll-animation类名一加,不支持的浏览器里所有滚动动画全部退化为静态内容,确保内容可读性第一,视觉锦上添花第二。
8.3 无障碍:prefers-reduced-motion
滚动驱动动画虽然比 JS 方案流畅,但它本质上还是"随视口变化的视觉位移",对晕动症用户并不友好。我强烈建议凡是做了位移、缩放类滚动动画的地方,都要包一层:
@media (prefers-reduced-motion: reduce) { .reveal, .card, .progress-bar { animation: none !important; } .read-progress { display: none; } }一个良好的习惯是:动画永远不能影响内容的最终可见性。降级之后,所有元素必须保持完整可见,不能让用户因为关闭动画选项而看不到内容。
8.4 性能实测的一些结论
使用滚动驱动动画后,我把一个原本有 12 个滚动监听事件的老页面重构了。重构前用 Performance Monitor 看,滚动时脚本执行时间大概 12-18ms,帧率时常掉到 40fps;重构后滚动时脚本执行时间直接归零,帧率稳定在 60fps。
原因很简单:浏览器把动画计算放到了合成器线程,不再走主线程的脚本+样式布局管线。带来的副作用就是,你如果想要动画过程中实时读取元素位置去做别的事情,纯 CSS 方案做不到,需要配合 Web Animations API,但那已经是另一个话题了。
9. 实际项目里的几个有意思的坑
9.1 坑一:子元素动画和父容器滚动条位置错位
如果你在一个overflow-y: auto的容器里用view()给子元素做动画,Safari 新版虽然支持view-timeline,但早期版本对容器滚动条高度的计算有 bug,导致子元素还没进入视口动画就开始播放。
规避方法:给容器设置一个明确的scroll-timeline,然后子元素引用这个具名时间线,不直接依赖view()的默认行为。这样即使某个浏览器对隐式视口识别有差异,你也能通过命名时间线强制绑定。
9.2 坑二:animation-range简写顺序容易写反
animation-range: entry 0% entry 100%这个写法是"阶段 + 位置"成对出现的。我在项目里见过有人写animation-range: 0% 100%,浏览器会把它理解为cover 0% cover 100%,行为可能不是你预期的。
如果你只需要覆盖整个 view() 区间,直接写animation-range: cover 0% cover 100%最安全。不要省关键字。
9.3 坑三:同时使用scroll()和view()导致元素闪动
一个元素绑了scroll()用于位移,另一个子元素绑了view()用于透明度变化,在滚动速度极快时可能产生闪帧。原因是两者的进度更新不在同一帧。解决办法是把两个动画都绑定到同一个命名时间线上,保证采样点一致。
9.4 坑四:有transform时 overflow 计算精度下降
元素带transform: scale()动画时,它的视觉边界和布局边界不同步。如果外层容器有overflow: hidden,在 Safari 下可能出现裁剪边缘有 1px 泄露。可以给容器加一个微小的padding: 1px,视觉上看不出来,却能避免裁切残影。
10. 再聊一下:为什么我建议你现在就动手替换老代码
滚动驱动动画不是我见过的第一个"纯 CSS 替代 JS"的炫技功能,但它和以往那些不一样的地方在于,它直接改变了动画和用户手势之间的关系。
之前用 JS 做滚动动画,本质上是在"翻译"用户的滚动位置;现在用 CSS 做,是在"描述"滚动位置和动画状态的映射关系。表达方式的转变,让代码变得更接近设计意图。设计师告诉你"图从底部滑入,滑到中间停住",你写出来的 keyframes 就是图从底部滑入、到中间停住的视觉过程,而不是一堆事件监听和 offset 计算。
这个特性目前在公司内部多个移动端纯前端项目中已经落地。实际表现最好的场景是品牌宣传页、活动页、落地页和产品展示页,这类页面通常需要一定的叙事感和视觉引导,而滚动驱动动画刚好能提供这种"用户自己控制阅读节奏"的交互体验。同时因为省掉了大量 JS 预计算,页面的首帧渲染和滚动流畅度都有明显提升,用户那边的反馈就是"这个页面滑起来真丝滑"。
如果你之前的代码里还有大段监听滚动调整样式的方法、或者在用一些"滚动进场动画"库,我建议找个不忙的下午,把某个小模块用原生实现替换掉试试。先从最简单的阅读进度条开始,再慢慢扩展到卡片进场和视差层。这个替换过程会逼你把动画的"触发时机"和"结束条件"想清楚,这本身就是一种很有价值的训练。
我自己在做完第一个滚动进度条之后,顺手把另外三个页面的进场动画全部改成了 CSS 实现,打包体积少了几十 KB,代码量减少了大半,心里就两个字:痛快。