JavaScript Library – Embla Carousel
前言
2022 年 4 月,我写了一篇 Swiper 介绍。
Swiper 是当时前端最多人使用的 Slider 库,没有之一,一骑绝尘。
但是!时过境迁,这两年已经有一匹神秘的黑马悄悄杀上来了。
它就是本篇的主角 -- Embla Carousel。
Embla Carousel 的卖点
Embla Carousel (简称 Embla) 何德何能?它凭什么在 Swiper 垄断的市场里能杀出一条血路🤔?
-
lightweight
Embla 最大的卖点是 lightweight。
据说它非常非常轻,且性能非常好。
p.s.:具体多轻我不清楚,但肯定比 Swiper 轻很多,我就是嫌 Swiper 又重又慢,才在 research 替代方案时找到了 Embla。
-
framework integration
Embla 可以很容易得集成到各种前端框架,比如:React/Next.js,Svelte,Vue,Solid.js 等 (哎哟,不错哦,没有 Angular)。
-
Customization and independent of CSS
Embla 不掺和 styling CSS,它只负责 JS 逻辑,并且开放底层 API 接口,让使用者可以根据自己项目需求订做专属的 Carousel (a.k.a Slider)。
以上三点无疑是近几年前端的趋势和刚需,雷军说过,站在风口上,猪也能飞,Embla 在这里做了最好的示范👍。
Swiper to Embla Carousel
想从 Swiper 直接切换到 Embla Carousel 并不容易,因为 Embla 比 Swiper low level,我们需要自己补上许多上层的封装才行。
本篇我会把我使用到的 Swiper 范围 (这篇里的内容) 用 Embla 来实现一遍,大家可以感受一下它俩在使用上的区别。
注:本篇不会从 0 基础讲起,最好你使用过 Swiper 或者其它 Slider Library。
参考:
安装
yarn add embla-carousel
HTML
<div class="slider"> <div class="slide-list"> <div class="slide"> <img src="../images/yangmi.jpg" alt="yangmi"> </div> <div class="slide"> <img src="../images/tifa.webp" alt="tifa"> </div> <div class="slide"> <img src="../images/nana.jpg" alt="nana"> </div> </div> </div>
HTML 结构和 Swiper 是一样的,slider > slide-list > slide 三层。
Styles
和 Swiper 不同,Embla 不涉及 CSS (注:first render 的时候不涉及 CSS 而已,交互时它肯定是要改 CSS 的)。
我们需要给 first render 的 CSS Styles,像这样
.slider { max-width: 512px; overflow: hidden; .slide-list { display: flex; .slide { flex-shrink: 0; width: 100%; img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; } } } }
目前的效果
只会看见一张图,因为另外两个 slide 被 overflow hide 起来了。
Scripts
import emblaCarousel from 'embla-carousel'; const sliderElement = document.querySelector<HTMLElement>('.slider')!; const slider = emblaCarousel(sliderElement, { container: '.slide-list', slides: '.slide', });
emblaCarousel 是一个函数,调用这个函数,传入 slider element 就可以了。
container 如果是 slider 的 first child 那可以不需要指定。(我指定只是为了演示)
slides 如果是 container 的 children 也可以不需要指定。(我指定只是为了演示)
相关源码在 EmblaCarousel.ts
到这里就已经可以跑起来了
Navigation
Swiper 有 built-in 完整的 navigation,Embla 没有。
Embla 只提供了底层操作 slider 的 API,上层需要我们自己写。
HTML

<div class="slider-container"> <div class="slider"> <div class="slide-list"> <div class="slide"> <img src="../images/yangmi1.jpg" alt="yangmi"> </div> <div class="slide"> <img src="../images/tifa.webp" alt="tifa"> </div> <div class="slide"> <img src="../images/nana.jpg" alt="nana"> </div> </div> </div> <div class="navigation"> <button class="prev"><</button> <button class="next">></button> </div> </div>
增加一个 container 还有 navigation buttons
Styles
给一点 Styles 美观一下

.slider-container { max-width: 512px; overflow: hidden; .slider { width: 100%; .slide-list { display: flex; .slide { flex-shrink: 0; width: 100%; img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; } } } } .navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; .prev, .next { font-size: 32px; font-weight: 700; padding: 16px 24px; background-color: lightblue; color: blue; border-width: 0; cursor: pointer; &[disabled] { opacity: 0.4; cursor: unset; pointer-events: none; } } } }
目前的效果
两个 button 还只是摆设,点击不会有任何效果。
Scripts
const sliderContainer = document.querySelector<HTMLElement>('.slider-container')!; const sliderElement = sliderContainer.querySelector<HTMLElement>('.slider')!; const slider = emblaCarousel(sliderElement); const prevBtn = sliderContainer.querySelector<HTMLButtonElement>('.navigation .prev')!; const nextBtn = sliderContainer.querySelector<HTMLButtonElement>('.navigation .next')!; for (const button of [prevBtn, nextBtn]) { const direction = button === prevBtn ? 'Prev' : 'Next'; // 监听 prev next button click // 当 user 点击后,调用 slider.scrollPrev() 或 scrollNext 方法来移动 slide button.addEventListener('click', () => slider[`scroll${direction}`]()); }
监听 prev 和 next button click,当 user 点击后,调用 slider.scrollPrev 或 scrollNext 来移动 slide。
效果
disabled 体验
navigation button 通常会有 disabled 体验。
当 user next 到最后一个 slide,我们需要 disable next button,让 user 知道已经到头了,不可以再继续 next。
相反,一开始在第一个 slide 时,我们需要 disable prev button。
首先,定义一个 handler
function handleStateChange() { prevBtn.disabled = !slider.canScrollPrev(); nextBtn.disabled = !slider.canScrollNext(); }
透过 slider.canScrollPrev 或 canScrollNext 方法来 detect 当前 slider 是否可以 next or prev。
如果当前是在第一个 slide 那 canScrollPrev 将返回 false,如果当前是在最后一个 slide 那 canScrollNext 将返回 false。
注:这两个方法还会考量 slider 是否支持 looping,如果支持 looping 的话,那不管当前在哪一个 slide,它们一定返回 true。
接着我们要监听 slider 的 slide 变更,然后 apply handler。
slider.on('init', handleStateChange); slider.on('select', handleStateChange); slider.on('reInit', handleStateChange);
有三个事件我们需要监听。
init 就是初始化完成,此时会是第一个 slide,所以 prev button 会 disabled。
select 是每一次 slide change,比如我们 click next / prev button,或者 swipe slide 的时候。
reInit 是当 slider 被修改 (e.g. add/remove slide, options change) 重置,或者 window / container / slides resize 的时候。
最终效果
looping 无限循环
Embla 支持 looping,配置 options 就可以了。
const slider = emblaCarousel(sliderElement, { loop: true });
效果
超级丝滑...这个 slide 的体验秒杀 Swiper。
上一 part 我们提到的 canScrollPrev 和 canScrollNext 在 looping 的情况下,一定返回 true。
Autoplay Plugin
Autoplay 是自动 swipe 功能,体验是这样 -- delay 几秒后,自动 swipe to next slide,然后又 delay 又 swipe 以此类推。
Embla Carousel 可以透过 Autoplay Plugin 实现这个功能。
安装 Plugin
首先,需要另外安装 npm package
yarn add embla-carousel-autoplay
setup & options
然后配置
import autoplay from 'embla-carousel-autoplay'; const slider = emblaCarousel(sliderElement, { loop: true }, [ autoplay(), ]);
emblaCarousel 第三个参数是用来配置 plugins 的。
autoplay 是一个函数,调用它会返回 plugin 实例。
它有一些 options 可以调
-
autoPlay({ delay: 1000 })
delay 多久后 auto swipe to next slide,默认是 4000 milliseconds。
-
playOnInit
是不是一开始就 start autoplay,默认是 true,如果我们想自己决定何时 start 那就 set to false,然后自己调用 API 让它 start,下面会教。
-
stopOnFocusIn
当 slider 内有任何 element 被 focused,autoplay 就会终止 (不是 pause,是 stop),默认是 true。
-
stopOnMouseEnter
mouse hover 到 slider,autoplay 就终止,默认是 false。
-
stopOnInteraction
interaction 指的是 slider 被 pointerdown,默认是 true。
比如 swipe to next slide 这个交互就涉及到了 pointerdown,所以它会 stop autoplay。我个人觉得 swipe 和 pointer down 是不同的交互,pointer down 应该只是 reset timer,等 pointer up 以后 autoplay 依然继续,只有 swipe to next slide 才真的会 stop autoplay,这样体验会比较好,尤其是在手机。
注:如果我们是透过 click navigation button 来 next slide,这可不会 stop autoplay 哦,因为 button 是在 slider 外面,click button 不会触发 slider 的 pointerdown。
-
stopOnLastSnap
autoplay 到最后一个 slide 就 stop,默认是 false。
如果没有设置 looping,在最后一个 slide 它依然会倒退回到第一个 slide。另外,它这个 stop 并不是完全 stop 死掉哦。
比如它在最后一个 slide stop了,但如果我们手动 swipe 去最后第二个 slide,这个 autoplay 还没死的哦,它会继续 auto swipe to 最后一个 slide 然后又 stop。嗯...这个设计还挺让人意外的。
-
默认 options
效果
Autoplay plugin 实例、方法、事件
想操控 autoplay,我们可以从 slider 里面取出 autoplay plugin 实例,然后调用它的各种方法
const autoplayPlugin = slider.plugins().autoplay; // autoplay plugin 实例
或者先创建实例,再传给 embla slider 也行。
const autoplayPlugin = autoplay({ delay: 1000 }); // autoplay plugin 实例 const slider = emblaCarousel(sliderElement, { loop: false }, [autoplayPlugin]); // 传入 embla slider
常用方法:
-
autoplayPlugin.play();
启动 timer,到点 auto swipe to next slide。
如果我们配置 playOnInit: false,那 autoplay 就不会开启,我们需要手动调用 play 方法让它启动。
小心坑:
EmblaCarousel.reInit 是一个重置 slider 的方法,细节下面会教,这里我们要知道它会导致 Autoplay 也 reInit,此时会依据回 playOnInit: false,autoplay 会 stop 掉。所以要特别留意,如果我们是 playOnInit: false 选择自己控制 play,那在 reInit 的时候也要决定是要继续 play 还是 stop。
-
stop()
stop 就是完全停掉 autoplay,timer 会马上被 clear 掉。
stop 了以后,可以用 play 让它恢复。
-
isPlaying()
返回 boolean,判断当前 autoplay 是否是启动状态。
-
reset()
reset 的意思是重算 timer。(注:autoplay 在启动状态下才能 reset 哦)比如说,timer delay 4 秒后会 auto swipe,当前是第二秒,我们执行 reset,那 timer 就重算,要再等 4 秒后才会 auto swipe。
-
timeUntilNext()
距离下一次 auto swipe 的时间,它返回的是 millisecond。
比如说,timer delay 4 秒后会 auto swipe,当前是第三秒,我们执行 timeUntilNext 会得到 1000,代表 1 秒后会 auto swipe。
-
init()
所有 plugin 都必须实现 init 方法,这个是给 slider 初始化 plugin 时用的,我们一般不会直接调用它。
-
destroy()
所有 plugin 都必须实现 destroy 方法,这个是给 slider destroy 时用的,我们一般不会直接调用它。
-
name
每个 plugin 都有名字,autoplay plugin 的名字叫 'autoplay'。
-
options
这个 options 对象就是我们调用 autoplay 函数时传入的那个 options 对象。
特别要留意的地方是,这个对象不包含 default options。
比如说,传入的 options 对象是 { delay: 1000 },default options 是这样
拿 autoplayPlugin.options.stopOnInteraction 将得到 undefined,而不是 true,因为 stopOnInteraction 是定义在 default options 里,而不是在我们传入的 options 对象里。
我个人觉得它这样设计很不方便,应该要提供一个 merged options 给我们用才对。
常用事件:
- autoplay:stop
const slider = emblaCarousel(sliderElement, { loop: false }, [autoplay({ delay: 1000 })]); const autoplayPlugin = slider.plugins().autoplay; slider.on(`autoplay:stop`, () => console.log('stop')); // 监听事件 window.setTimeout(() => autoplayPlugin.stop(), 1000); // 触发 stop event
事件监听是透过 slider.on 绑定的,事件名的规范是 autoplayPlugin.name + ':' + supportedEventName。
stop event 会在 autoplay stop 的时候触发,很多情况会导致 autoplay stop,比如 focus, hover, interaction, last slide, call stop method,不管什么情况,只要状态从 play to stop,它就会触发。
-
autoplay:play
状态从 stop to play 时触发。
注:play on init 不会触发,因为我们监听的比较晚,它的顺序是这样:
emblaCarousel 里面会调用 autoplayPlugin.init,init 里面会调用 startAutoplay,因为默认 playOnInit: true,startAutoplay 里面会 fire 'autoplay:play' event,
等 emblaCarousel 跑完,我们才调用 slider.on('autoplay:play'),此时 event 已经 fire 掉了。
-
autoplay:select
autoplay 开启后,会先 delay,等 timer 到点后,它会 auto swipe,这个 select event 就是在 auto swipe 的时候触发的。
-
autoplay:timerset
autoplay 的流程是 delay > swipe > delay > swipe,每一次 delay 都是用 setTimeout 完成的,每一次 set 这个 timer 都会 fire 'autoplay:timerset' event。
-
autoplay:timerstopped
顾名思义,当 clearTimeout 时它就会 fire 'autoplay:timerstopped' event。(e.g. when autoplay stop 的时候,注:stop 会比 timerstopped 早一拍 fire)。
Change autoplay options?
如果我们想修改 options 可以吗?
比如说,一开始配置 delay: 1000,2 秒后我想改去 delay: 4000。
我们先天真的试一试
const autoplayPlugin = autoplay({ delay: 1000 }); const slider = emblaCarousel(sliderElement, { loop: false }, [autoplayPlugin]); window.setTimeout(() => { autoplayPlugin.options.delay = 4000; // 2 秒后修改 delay 从 1 秒变 4 秒 }, 2000);
结果什么都没有改变,依然维持 delay 1 秒。
如果我们加一句 reset 呢?
autoplayPlugin.reset();
还是不行。
那 stop > change delay > play 呢?
autoplayPlugin.stop(); autoplayPlugin.options.delay = 4000; // 2 秒后修改 delay 从 1 秒变 4 秒 autoplayPlugin.play();
通通不行。
why? 看一看源码
每当 setTimeout 的时候,它会从 delay 对象中拿出 options 的 4 秒。
这个 delay 对象是在 plugin.init 时制作好的,并且后续没有监听 options 变更,所以我们修改 options 它是不管的。
因此,倘若我们想修改 options,唯一的方法就是手动调用 destory,然后再调用 init,让它整个 plugin 重启。
调用 destroy 不难,但调用 init 就有点困难了。
init 方法需要一个 optionsHandler 对象
这个对象是透过一个内部函数 OptionsHandler 创建的 (在执行 emblaCarousel 函数的时候,注:Embla 的函数命名规范是 PascalCase,而不是我们常用的 camelCase,在翻阅源码的时候要看得懂哦)
Embla 没有公开这个 OptionsHandler 函数,所以我们无法调用 plugin.init 方法。
emblaCarousel.reInit (a.k.a reActive)
我们只剩下最后一条路 -- emblaCarousel.reInit 方法
这个方法会重启整个 slider,所有的 plugin 会被 destroy 然后再 init。
重启不会把 slide 跳回第一个,而是保持在当前位置。
如果我们传入新的 options 或 plugins,那它会 merge 之前的。
最后的实现代码是这样
window.setTimeout(() => { autoplayPlugin.options.delay = 4000; // 2 秒后修改 delay 从 1 秒变 4 秒 slider.reInit(); }, 2000);
当 Autoplay 遇上 Navigation
看到吗,Autoplay 和 Navigation 打起来了。
虽然 stopOnInteraction: true,但 navigation 操作对 autoplay plugin 来说并不算是 interaction,只有 slider pointerdown 才算是 interaction,所以 navigation 操作不会 stop autoplay。
官方给的例子是这样解决的
监听 navigation button click,然后调用 reset 或 stop 来控制 autoplay。
这个做法可以达到效果,但有一点点扣管理分。
因为这样做会把 navigation 和 autoplay 的关系绑的很紧,而且倘若哪天再出现一个 pagination (另一种操作 slide 的方式),我们又得再写一套类似的逻辑给它,这样很繁琐。
我这里有一个 idea,我们可以监听 select change 事件,如果是 select change by not autoplay,那我们就 stop or reset autoplay。
这样就可以 cover navigation 和 pagination 甚至其它更多的 slide 操作。
代码大概是这样
// 监听每一次的 select slider.on('select', () => { // 判断这一次的 select 是 trigger by autoplay or not let isAutoSelect = false; // 因为是先触发 select 后触发 autoplay:select (同步) // 所以我们可以利用这一点来判断 select 是 trigger by autoplay or not const callback = () => { isAutoSelect = true; slider.off('autoplay:select', callback); }; slider.on('autoplay:select', callback); queueMicrotask(() => { slider.off('autoplay:select', callback); // 如果是 autoplay 那就 skip if (isAutoSelect) return; // 如果 select trigger by navigation, pagination 或其它的,那我们就 stop or reset autoplay。 (autoplayPlugin.options.stopOnInteraction ?? true) ? autoplayPlugin.stop() : autoplayPlugin.reset(); }); });
RxJS 的写法是这样
fromEvent(slider, 'select') .pipe( switchMap(() => merge(fromEvent(slider, 'autoplay:select').pipe(map(() => true)), of(false).pipe(observeOn(asapScheduler))).pipe( take(1), ), ), filter(isAutoplaySelect => !isAutoplaySelect), ) .subscribe(() => (autoplayPlugin.options.stopOnInteraction ?? true) ? autoplayPlugin.stop() : autoplayPlugin.reset(), );
提醒:
上面使用 autoplayPlugin.stop 或许并没有很恰当,因为
然后
所以,用 autoplayPlugin.destroy 可能更合适。(我觉得是因为它源码的实现方式是这样,所以我们才被迫得使用 destroy,总之不算是一个正规方案,算是一种 hacking way,谨用)
Slides per view & Slides to scroll
红框是 slide,绿框是 view (a.k.a scroll snap)。
slides per view 是指,一个 view 里面有多少个 slide。
我们上面提过的例子,都是一个 view 一个 slide,而这一个则是一个 view 两个 slides。
那要如何实现它呢?
Swiper 的 slides per view 主要是靠 JavaScript 来完成的 (包括布局)。
而 Embla 的 slides per view 则主要是靠 CSS Styles 来完成的 (交互依然是靠 JavaScript)。
Styles
我个人比较习惯用 grid 做 slider 布局
所以这里把之前的 flex 改成 grid (注:两种布局方式都可以达到最终效果,所以选哪个看个人喜好就好)。
每一个 column (也就是 slide) width 是 50%,那就代表一个 view 里会有两个 slides。
效果
add slide gap
slide 与 slide 之前没有 gap,不好看,我们加 gap 进去
.slide-list { --slides-per-view: 2; --slide-gap: 16px; display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - (var(--slide-gap) * (var(--slides-per-view) - 1))) / var(--slides-per-view)); gap: var(--slide-gap); }
直接加 gap 会影响到 slide width,所以我们需要写一些简单的 calculation。
效果
排版虽然是对的,但交互会有一些体验问题
当鼠标在 gap 局域 swipe 时,它会不小心 select 到 slide。
这是因为 gap 区域是 div.slide-list 的 area,它是 slide 的 parent 了。
我们可以参考官网的实现方式来解决这个问题,它的 gap 是用 slide padding-left 做出来的。
.slide-list { --slides-per-view: 2; --slide-gap: 16px; display: grid; grid-auto-flow: column; grid-auto-columns: calc(100% / var(--slides-per-view)); margin-left: calc(-1 * var(--slide-gap)); .slide { padding-left: var(--slide-gap); } }
首先给每个 slide 一个 padding-left 作为 slide gap。
第二步是给 .slide-list 一个 negative margin-left,目的是把第一个 slide 的 padding-left 吃掉。
效果
小心坑
需要特别留意的一点是 slide-list 和 slide 的 bounding client rect 和 width。
绿色线条是我们直觉中的 rect 和 width。
但由于 slide-list 加了 negative margin-left,slide 加了 padding-left 所以 bounding client rect 和 width 都被影响了。
红色才是它真实的 bounding client rect 和 width。
slide-list 和 slide 的 width 都大了,slide-list 和 slide 的 rect.left 都少了。
所以如果有使用到 bounding client rect 或 width 一定要注意,它是不符合我们直觉的。
计算 slide width
这里给一个计算 slide width 的例子
slider 的 width 是 512px
slider-list 的 gap 是 12px(也就是 slider-list 有 margin-left: -12px 和每个 slide 的 padding-left:: 12px)
因此,slider-list 的 width 是 512px + 12px = 524px(因为 margin-left: -12px 所以 width 变大了)。
要求 slide per view 显示 1.1 个 slide:
slide width 的 formula 是 100% / 1.1
100% 指的是 slide-list 的 width,也就是 524px。
524px / 1.1 = 476.36px,这就是最终的 slide width 了
slides per group
设置 slides per view = 2 之后,我们去 swipe 它会发现体验怪怪的。
swipe 一下只移动了半个 slide。原因是 alignment 跑掉了。
EmblaOptions.align
slider 默认 align 是 'center',我们 swipe 多几下就能看出这个 align: 'center' 的含义了
center 会是一个完整的 slide,然后左右 slide 各占 50% width,这就是 align: center 的意思。
我们把 align 换成 'start' 看看效果
const slider = emblaCarousel(sliderElement, { align: 'start' });
效果
yes,这是我们比较熟悉的 swipe 体验。
EmblaOptions.slidesToScroll
Swiper 有一个感念叫 slides per group,意思是当我们 swipe 的时候,它会移动多少个 slide。
比如说,在一个 view 一个 slide 的情况下,swipe 通常就是一个 slide。
而在一个 view 两个 slides 的情况下,swipe 一次我们可以选择移动一个 slide 或者移动两个 slides。
上面是一个 swipe 一个 slide 的体验,下面我们看看一个 swipe 两个 slides 的体验。
const slider = emblaCarousel(sliderElement, { align: 'start', slidesToScroll: 2 });
slidesToScroll: 2 表示 scrollNext 会直接跳两个 slides,而不是默认的一个。
效果
另外,slidesToScroll 还支持 'auto' 值。
const slider = emblaCarousel(sliderElement, { align: 'start', slidesToScroll: 'auto' });
'auto' 的意思就是依据 slides per view。
比如 slides per view 是 3 的话,那 slidesToScroll 也自动会是 3。
SlidesInView
EmblaCarousel.slidesInView 是一个方法,它会返回当前有哪些 slides 在 view 里面 (这个 view 指的是 slider 可见区域)。
我们看一个官方的例子
一个 view 一个 slide,目前显示的是一号 slide,也就是第 0 个,index 0。
emblaApi.on('slidesInView', () => console.log(emblaApi.slidesInView())); // [0, 1]
slidesInView 返回的是 [0, 1],意思是说,index 0 和 1 slide 目前显示在 view 里。
呃...这不对啊🤔明明显示的只有 index 0 啊...
Github Issue – slidesInView returns one too many slides
作者给出了解答
slidesInView 是依靠 IntersectionObserver 来计算的,源码在 SlidesInView.ts。
我们自己用 IntersectionObserver 测一下看看
window.setTimeout(() => { const io = new IntersectionObserver(entries => { console.log(entries.map(e => e.isIntersecting)); // [true, true, false, false, false] }); const slides = Array.from(viewportNode.querySelectorAll('.embla__slide')); slides.forEach(slide => io.observe(slide)); }, 2000); // delay 是为了等它 render 完
可以看到 5 个 slides 里,头两个 (index 0 和 1) isIntersecting 真的是 true。
这种诡异的现象通常是微差或者 "刚刚好动到要不要算" 造成的,作者给的解方是 -- 设置 threshold。
const emblaApi = EmblaCarousel(viewportNode, { inViewThreshold: 0.01 });
不懂原理想明白的读友可以看这篇。
Text Selection
slide 里面的 text 是很难被 select 的。
double click select text 可以,但 drag select 就不行。
因为 drag 会移动 slide,这和 select text 交互是打架的。
Swiper 可以透过 class swiper-no-swiping 解决这种冲突,很遗憾 Embla 没有支持。
相关 Issue:
Stack Overflow – Embla Carousel - select inner text
三个思路,
第一,给 slider 添加 cursor styles
.slider { cursor: grab; user-select: none; }
告知 user 无法 select text。
第二,配置 watchDrag: false
const slider = emblaCarousel(sliderElement, { watchDrag: false });
直接 disable 掉 drag 的功能,user 只能透过其它方式移动 slide,比如 navigation 或 pagination。
第三,模拟 swiper-no-swiping
添加 'drag-disabled' class 到 slide 里的 heading element
<h1 class="drag-disabled">Yang Mi</h1>
代表这个 element 不可以 drag。
接着一样是用 watchDrag,但这一次是提供一个判断函数
const slider = emblaCarousel(sliderElement, { watchDrag: (_slider, event) => { if ((event.target as HTMLElement).classList.contains('drag-disabled')) { return false; } return true; }, });
return false 或 undefined 就是阻止移动 slide,return true 则是允许移动 slide,源码长这样
题外话:watchDrag 还可以用来实现 nested slider 哦。
另外,补上一个烂招,监听 slider mousedown 和 touchstart 事件,然后 stopPropagation 阻止 Embla drag。
const slider = emblaCarousel(sliderElement); for (const eventName of ['touchstart', 'mousedown']) { sliderElement.addEventListener( eventName, e => { if ((e.target as HTMLElement).classList.contains('drag-disabled')) { e.stopPropagation(); } }, { capture: true }, ); }
这里必须赶在 Embla 的前面,所以需要使用 capture: true。
Embla 会 binding 各种事件到 node (这个 node 就是 slider element) 上,我们赶在它之前 stopPropagation 就可以阻止掉它们了。
效果
Yang Mi 可以 select text 了。
Breakpoints
要在不同的 viewport size 呈现不同的 slide 布局或 options,我们需要配置 breakpoints。
CSS media query
slides 布局通常只需要定义 CSS media query 就可以了。
Embla 本身会监听 window resize,然后 getComputedStyle 拿到当前的 Styles 做相应的处理。
比如
slides per view 默认是 1,slide gap 默认是 0px。
在 viewport width 1920px 时,slides per view 变成 2,slide gap 变成 16px。
我们只需要 CSS 就够了,JavaScript 不需要写。
效果
breakpoints options
需求:默认要 looping,大过 1024px 不要 looping,大过 1920 又要 looping。
const slider = emblaCarousel(sliderElement, { align: 'start', slidesToScroll: 'auto', loop: true, breakpoints: { '(min-width: 1024px)': { loop: false, }, '(min-width: 1920px)': { loop: true, }, }, });
上面有三个 loop options 定义,它的覆盖逻辑 (Object.assign) 是从下到上 (下面盖上面,下面赢),所以通常我们定义 media query 是从小(上)到大(下)。
效果
另外,Embla 有一点比 Swiper 强,Embla 可以依据 braekpoints 配置 acitve or inactive。(我印象中,Swiper 是无法 inactive 的)
const slider = emblaCarousel(sliderElement, { align: 'start', slidesToScroll: 'auto', breakpoints: { '(min-width: 1024px)': { active: false, }, '(min-width: 1920px)': { active: true, }, }, });
直接改 active 属性就可以了。
get current options on reInit
breakpoint change 导致 options change,Embla 底层会使用 reInit 方法重置,reInit 事件会触发。
另外,我们可以监听 reInit 事件,并获取当前的 options 做逻辑
slider.on('reInit', () => console.log('reInit', slider.internalEngine().options));
这个 options 是完整 (merged & breakpoints 过滤过) 的 options,而不是我们传入的 partial options。
Pagination
Swiper 有 bulit-in 的 pagination,也支持 full custom pagination。
Embla 没有 built-in 的 pagination,我们需要像 navigation 那样,使用 Embla 底层 API,自己写上层实现代码。
实现要点
paignation 长这样
下面一粒一粒的叫 bullet。
三个要点:
-
点击 bullet 会移动 slide
-
active bullet
active bullet 就是那颗比较亮的 bullet,slide 在第几个,active bullet 就要在第几个。
-
bullet 的数量
上面的例子有 6 个 slides (6 张图),一个 view 显示一个 slide,bullet 有 6 粒。
下面这个例子一样是 6 个 slides,但一个 view 显示了两个 slides,bullet 变成了 3 粒。
所以,bullet 的数量是看有多少个 view 决定的。
具体实现
HTML
首先是 HTML

<div class="pagination"> <template><div class="bullet"></div></template> </div>
bullet 的数量依据 view count,我们用 JavaScript 动态输出会比较容易管理。(用 CSS 只能稿 display: none 会比较乱)
HTML 定义一个 bullet template 就好。
Styles
没什么特别的,就是美观一下而已

.pagination { --bullet-size: 24px; margin-top: 16px; display: flex; justify-content: center; gap: 16px; height: var(--bullet-size); /* 提早给空间 */ .bullet { width: var(--bullet-size); height: var(--bullet-size); border-radius: 999px; border: 1px solid blue; cursor: pointer; &.active { background-color: lightblue; } } }
Scripts
const pagination = document.querySelector<HTMLElement>('.pagination')!; const bulletTemplate = pagination.querySelector('template')!; function rebuildPagination() { // 当前在第几个 view const currentViewIndex = slider.selectedScrollSnap(); // 总共有几个 view const viewCount = slider.scrollSnapList().length; const bulletsFrag = document.createDocumentFragment(); for (let index = 0; index < viewCount; index++) { // 创建 bullet element based on view count const bulletTemplateFrag = bulletTemplate.content.cloneNode(true) as DocumentFragment; const bullet = bulletTemplateFrag.firstElementChild!; // add click event to bullet bullet.addEventListener('click', () => slider.scrollTo(index)); if (index === currentViewIndex) { // set active class to bullet bullet.classList.add('active'); } bulletsFrag.appendChild(bullet); } // clear and re-append bullets pagination.innerHTML = ''; pagination.appendChild(bulletsFrag); } // 三种情况有可能导致 bullet 数量或 active 变更,当变更时我们就 rebuild pagination slider.on('select', rebuildPagination); slider.on('init', rebuildPagination); slider.on('reInit', rebuildPagination);
使用到了两个 Embla API
-
EmblaCarousel.selectedScrollSnap 方法
scroll snap 就是 view 的别名 (alias)。
selectedScrollSnap 会返回当前 view index (当前在第几个 view)。
-
EmblaCarousel.scrollSnapList 方法
它会返回一个 array,长这样 [-0, 0.2, 0.4, 0.6, 0.8, 1] 或着这样 [-0, 0.5, 1]。
里面的号码不重要,array.length 代表 view 的数量,也就是我们要的 bullet 数量。
最终效果
Dynamic bullets
当 bullets 太多的时候会不好看,我们可以做成 dynamic bullets 限制它的数量。
长这样
附上完整代码,就不解释了。
HTML

<div class="slider-container"> <div class="slider"> <div class="slide-list"> <div class="slide"> <img src="../images/yangmi1.jpg" alt="yangmi1"> </div> <div class="slide"> <img src="../images/tifa.webp" alt="tifa"> </div> <div class="slide"> <img src="../images/nana.jpg" alt="nana"> </div> <div class="slide"> <img src="../images/yangmi2.jpg" alt="yangmi2"> </div> <div class="slide"> <img src="../images/yangmi3.jpg" alt="yangmi3"> </div> <div class="slide"> <img src="../images/dilireba.jpg" alt="dilireba"> </div> <div class="slide"> <img src="../images/yangmi1.jpg" alt="yangmi1"> </div> <div class="slide"> <img src="../images/tifa.webp" alt="tifa"> </div> <div class="slide"> <img src="../images/nana.jpg" alt="nana"> </div> <div class="slide"> <img src="../images/yangmi2.jpg" alt="yangmi2"> </div> <div class="slide"> <img src="../images/yangmi3.jpg" alt="yangmi3"> </div> <div class="slide"> <img src="../images/dilireba.jpg" alt="dilireba"> </div> </div> </div> <div class="pagination"> <template><div class="bullet"></div></template> <div class="bullet-list"></div> </div> </div>
Styles

.slider-container { max-width: 512px; overflow: hidden; .slider { width: 100%; .slide-list { --slides-per-view: 1; --slide-gap: 0px; display: grid; grid-auto-flow: column; grid-auto-columns: calc(100% / var(--slides-per-view)); margin-left: calc(-1 * var(--slide-gap)); .slide { padding-left: var(--slide-gap); img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; } } @media (width >= 768px) { --slides-per-view: 2; --slide-gap: 16px; } } } .pagination { margin-top: 16px; --max-bullet-count: 5; --bullet-size: 24px; --bullet-gap: 16px; max-width: calc( (var(--max-bullet-count) * var(--bullet-size)) + ((var(--max-bullet-count) - 1) * var(--bullet-gap)) ); margin-inline: auto; overflow: hidden; .bullet-list { --active-index: 0; // JS will fill in margin-left: calc(50% - (var(--bullet-size) / 2)); transition: transform 0.4s; transform: translateX(calc(-1 * (var(--active-index) * (var(--bullet-size) + var(--bullet-gap))))); display: flex; gap: var(--bullet-gap); height: var(--bullet-size); .bullet { flex-shrink: 0; width: var(--bullet-size); height: var(--bullet-size); border-radius: 999px; border: 1px solid blue; cursor: pointer; transition: transform 0.4s; &.active { background-color: lightblue; } &:not(.active) { transform: scale(0.5); } &:has(+ .active) { transform: scale(0.75); } &.active + .bullet { transform: scale(0.75); } } } } }
Scripts

import emblaCarousel from 'embla-carousel'; const sliderContainer = document.querySelector<HTMLElement>('.slider-container')!; const sliderElement = sliderContainer.querySelector<HTMLElement>('.slider')!; const slider = emblaCarousel(sliderElement, { align: 'start', slidesToScroll: 'auto', inViewThreshold: 0.1, }); const pagination = document.querySelector<HTMLElement>('.pagination')!; const bulletList = pagination.querySelector<HTMLElement>('.bullet-list')!; const bulletTemplate = pagination.querySelector('template')!; const cachedBullets: HTMLElement[] = []; function rebuildPagination() { const currentViewIndex = slider.selectedScrollSnap(); const viewCount = slider.scrollSnapList().length; bulletList.style.setProperty('--active-index', currentViewIndex.toString()); cachedBullets.forEach(bullet => bullet.classList.remove('active')); if (cachedBullets.length > viewCount) { const bulletsToRemove = cachedBullets.splice(viewCount); bulletsToRemove.forEach(bullet => bullet.remove()); } if (cachedBullets.length < viewCount) { const gap = viewCount - cachedBullets.length; const bulletsToAdd = new Array(gap).fill(undefined).map((_, index) => { const bulletTemplateFrag = bulletTemplate.content.cloneNode(true) as DocumentFragment; const bullet = bulletTemplateFrag.firstElementChild as HTMLElement; const scrollToIndex = cachedBullets.length + index; bullet.addEventListener('click', () => slider.scrollTo(scrollToIndex)); return bullet; }); cachedBullets.push(...bulletsToAdd); const frag = document.createDocumentFragment(); bulletsToAdd.forEach(bullet => frag.appendChild(bullet)); bulletList.appendChild(frag); } cachedBullets[currentViewIndex].classList.add('active'); } slider.on('select', rebuildPagination); slider.on('init', rebuildPagination); slider.on('reInit', rebuildPagination);
Auto Height
我们来看一个场景
粉色是整个 slider,为什么下半段会空空?
因为有隐藏的 slide 内容很多,很高。
后面隐藏的 slide 把整个 slider 撑高了。
显然对用户来说这个体验不 ok,因为这会让用户感到困惑 -- 怎么下面空空的🤔?
我们可以用 Auto Height Plugin 来解决这个问题 (注:Swiper 也有这个功能)。
安装 package
yarn add embla-carousel-auto-height
import plugin 函数,调用它创建 plugin 实例,再传给 Embla Carousel 就行了。(和 Autoplay Plugin 玩法一样)
import autoHeight from 'embla-carousel-auto-height'; const slider = emblaCarousel( sliderElement, { align: 'start', slidesToScroll: 'auto', inViewThreshold: 0.1 }, [autoHeight()], );
添加 Styles
align-items: flex-start 的目的是让每一个 slide height 变成 hug content (默认是 stretch,会被其它 slide 拉大,这不是我们要的)。
transition 只是为了体验丝滑
效果
当用户 swipe 到比较高的 slide 时,slider 的 height 才会撑开。
Auto Height 的计算方式
上面例子有 6 个 slides (6 张图),每一个 view 显示两个 slides。
我们删除最后一个 slide,变成 5 个 slides,然后 swipe 到最后一个 view,它长这样
第 4 个 slide 没有显示所有的内容,这是为什么呢?
我翻了一下源码,发现它使用的是 slideRegistry 来获取当前 view 的 slides,而不是我们上面提过的 slidesInView。
我们测一下
function detect() { window.setTimeout(() => { console.log('slidesInView', slider.slidesInView()); console.log('slideRegistry', slider.internalEngine().slideRegistry[slider.selectedScrollSnap()]); }, 500); } slider.on('select', detect); slider.on('init', detect);
效果
可以看到,最后一个 view,slideRegistry 只拿到了 slide index 4 (也就是第 5 个 slide),所以在计算 auto height 时,它只用了第 5 个 slide 的高度,没有把第 4 个 slide 考量进去。
而第 4 个 slide 比第 5 个高,那最终第 4 个 slide 就被 overflow clip 掉了。
我提了一个 Issue,希望有人能解释清楚这是不是他们预想中的体验。
我的猜测是这样,slidesInView 依赖 IntersectionObserver,如果要依靠它的话,需要等到 slide 完全停下来才准,这会导致 auto height 很晚才去 update height,可能这个体验也不 ok,所以作者在这里做了一个 trade-off。
要达到我预期的效果,唯一的办法就是不要靠 IntersectionObserver,而是自己依据 slide 的 boundingClientRect 计算出 slides in view。
Auto height based on slides in view
我尝试了一下自己计算 slides in view,果然有点难度,可能就是这个原因 Embla 才不基于 slides in view 吧。
这里分享我的尝试
HTML

<div class="slider-container"> <div class="slider"> <div class="slide-list"> <div class="slide"> <div class="card"> <img src="../images/yangmi1.jpg" alt="yangmi1"> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Debitis, quidem!</p> </div> </div> <div class="slide"> <div class="card"> <img src="../images/tifa.webp" alt="tifa"> <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Nemo aliquid consequatur quis quibusdam quam soluta nihil numquam, tempora sit amet?</p> </div> </div> <div class="slide"> <div class="card"> <img src="../images/nana.jpg" alt="nana"> <p>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Voluptates illo dolore iste rerum eum porro, aperiam assumenda et ad veniam vitae numquam, suscipit, perferendis hic. Ullam voluptatum quos impedit eaque?</p> </div> </div> <div class="slide"> <div class="card"> <img src="../images/yangmi2.jpg" alt="yangmi2"> <p>Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ducimus eius dignissimos, earum nam architecto molestiae saepe dolore quidem. Placeat, quasi nihil dolor nulla consequatur nam perferendis vero. Fuga consectetur, earum eos, dolore magni consequuntur non officia dolores minus est excepturi.</p> </div> </div> <div class="slide"> <div class="card"> <img src="../images/yangmi3.jpg" alt="yangmi3"> <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit. Illum consequatur laboriosam doloribus tempora atque aperiam?</p> </div> </div> </div> </div> </div>
Styles

.slider-container { max-width: 512px; overflow: hidden; .slider { width: 100%; background-color: pink; .slide-list { --slides-per-view: 1; --slide-gap: 0px; display: grid; grid-auto-flow: column; grid-auto-columns: calc(100% / var(--slides-per-view)); align-items: flex-start; transition: height 0.4s; margin-left: calc(-1 * var(--slide-gap)); .slide { padding-left: var(--slide-gap); .card { img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; } p { padding: 16px; line-height: 1.5; font-size: 18px; } } } @media (width >= 768px) { --slides-per-view: 2; --slide-gap: 16px; } } } }
Scripts

import emblaCarousel, { type EmblaOptionsType } from 'embla-carousel'; import './home.scss'; const sliderContainer = document.querySelector<HTMLElement>('.slider-container')!; const sliderElement = sliderContainer.querySelector<HTMLElement>('.slider')!; interface Line { left: number; right: number; } // note 用法:可以用 type.startsWith('in-') 来判断是否有 intersecting 的 type IntersectionType = 'out-left' | 'in-left' | 'in-center' | 'in-right' | 'out-right' | 'in-over'; function getIntersectionType(frameLine: Line, itemLine: Line): IntersectionType { if (itemLine.right < frameLine.left) return 'out-left'; if (itemLine.left < frameLine.left && itemLine.right >= frameLine.left && itemLine.right <= frameLine.right) return 'in-left'; if (itemLine.left >= frameLine.left && itemLine.right <= frameLine.right) return 'in-center'; if (itemLine.right > frameLine.right && itemLine.left <= frameLine.right && itemLine.left >= frameLine.left) return 'in-right'; if (itemLine.left > frameLine.right) return 'out-right'; if (itemLine.left < frameLine.left && itemLine.right > frameLine.right) return 'in-over'; throw new Error('never'); } interface SlideRectInView { element: HTMLElement; line: Line; intersectionType: IntersectionType; // note 解释:把 PointerEvent.clientX 放进来,可以判断是否在这个 slide 里面 isPointerInside(pointer: PointerEvent): 'left' | 'right' | 'none'; } const sliderOptions: EmblaOptionsType = { align: 'start', slidesToScroll: 'auto', inViewThreshold: 0.1, containScroll: false, }; const slider = emblaCarousel(sliderElement, sliderOptions); function updateHeight() { const slides = slider.slideNodes(); if (slides.length === 0) return; const slideRectsInViews: SlideRectInView[][] = []; const sliderPaddingLeft = parseFloat(window.getComputedStyle(slider.rootNode()).paddingLeft); const sliderPaddingRight = parseFloat(window.getComputedStyle(slider.rootNode()).paddingRight); const slideList = slider.containerNode(); const slideListRect = slideList.getBoundingClientRect(); const slideListMarginLeft = parseFloat(window.getComputedStyle(slideList).marginLeft); const viewWidth = sliderPaddingLeft + (slideListMarginLeft < 0 ? slideListRect.width + slideListMarginLeft : slideListRect.width) + sliderPaddingRight; const slideLines = slides.map<Line>(slide => { const slideRect = slide.getBoundingClientRect(); const slidePaddingLeft = parseFloat(window.getComputedStyle(slide).paddingLeft); const left = slideRect.left - slideListRect.left; return { left: left + 1 + sliderPaddingLeft, right: left + sliderPaddingLeft + slideRect.width - slidePaddingLeft, }; }); const { slidesToScroll, containScroll, align } = slider.internalEngine().options; if (typeof slidesToScroll === 'number') { const totalView = Math.ceil(slides.length / slidesToScroll); for (let viewIndex = 0; viewIndex < totalView; viewIndex++) { let viewLine: Line = undefined!; if (viewIndex === 0) { viewLine = { left: 1, right: viewWidth, }; } const firstSlideLineIndex = viewIndex * slidesToScroll; if (viewIndex > 0) { const firstSlideLine = slideLines[firstSlideLineIndex]; viewLine = calcViewLine(firstSlideLine); } if (containScroll !== false && viewIndex > 0 && firstSlideLineIndex >= slideLines.length - slidesToScroll) { const lastSlideLine = slideLines.at(-1)!; viewLine.right = lastSlideLine.right; viewLine.left = viewLine.right - viewWidth + 1; } slideRectsInViews.push(getSlidesInView(viewLine)); } } else { let viewIndex = 0; while (true) { let viewLine: Line = undefined!; if (viewIndex === 0) { viewLine = { left: 1, right: viewWidth, }; } if (viewIndex > 0) { let firstSlideLine: Line = undefined!; const lastSlideInView = slideRectsInViews.at(-1)!.at(-1)!; if (lastSlideInView.intersectionType === 'in-right') { firstSlideLine = lastSlideInView.line; } else { firstSlideLine = slideLines[slideLines.indexOf(lastSlideInView.line) + 1]; } viewLine = calcViewLine(firstSlideLine); } const lastSlideLine = slideLines.at(-1)!; const isLastView = lastSlideLine.right <= viewLine.right; if (containScroll !== false && isLastView && viewIndex !== 0) { viewLine.right = lastSlideLine.right; viewLine.left = viewLine.right - viewWidth + 1; } slideRectsInViews.push(getSlidesInView(viewLine)); viewIndex++; if (isLastView) break; } } const height = Math.max( ...slideRectsInViews[slider.selectedScrollSnap()].map( slideInView => slideInView.element.getBoundingClientRect().height, ), ); slideList.style.height = `${height}px`; function getSlidesInView(viewLine: Line): SlideRectInView[] { const slideInfos = slideLines.map<SlideRectInView>((slideLine, index) => ({ element: slides[index], line: slideLine, intersectionType: getIntersectionType(viewLine, slideLine), isPointerInside({ clientX, clientY }) { // note 解忧 + 性能隐患: // 每次 swipe 之后 slideListRect 就不同了,我没有缓存起来,所以这里需要每次拿新的才准。 // 既然 slideListRect 都拿了,那其它的也不计较了,统统不缓存,通通拿新的呗。 if (clientY < slides[index].getBoundingClientRect().top) return 'none'; const slideListRect = slideList.getBoundingClientRect(); const slideListMarginLeft = parseFloat(window.getComputedStyle(slideList).marginLeft); const sliderPaddingLeft = parseFloat(window.getComputedStyle(slider.rootNode()).paddingLeft); const adjustedSlideListRectLeft = slideListRect.left + (slideListMarginLeft < 0 ? Math.abs(slideListMarginLeft) : 0) - sliderPaddingLeft; const adjustedPointerX = clientX - adjustedSlideListRectLeft + 1; const center = slideLine.left + (slideLine.right - slideLine.left + 1) / 2; if (adjustedPointerX >= slideLine.left && adjustedPointerX < center) return 'left'; if (adjustedPointerX >= center && adjustedPointerX <= slideLine.right) return 'right'; return 'none'; }, })); const slidesInView = slideInfos.filter(e => e.intersectionType.startsWith('in-')); return slidesInView; } function calcViewLine(firstSlideLine: Line): Line { let viewLine: Line = undefined!; if (align === 'start') { const viewLineLeft = firstSlideLine.left - sliderPaddingLeft; viewLine = { left: viewLineLeft, right: viewLineLeft - 1 + viewWidth, }; } if (align === 'center') { const halfSlideWidth = (firstSlideLine.right - firstSlideLine.left + 1) / 2; const slideLineCenter = firstSlideLine.left - 1 + halfSlideWidth; const halfViewWidth = viewWidth / 2; const viewLineLeft = slideLineCenter - halfViewWidth + 1; viewLine = { left: viewLineLeft, right: viewLineLeft - 1 + viewWidth, }; } if (align === 'end') { const viewLineRight = firstSlideLine.right + sliderPaddingRight; viewLine = { left: viewLineRight - viewWidth + 1, right: viewLineRight, }; } return viewLine; } } slider.on('init', updateHeight); slider.on('reInit', updateHeight); slider.on('select', updateHeight);
效果
和 Auto Heigh Plugin 的区别是在最后一个 view,它的第 4 个 slide 会被 overflow,我的不会。
我解释一下实现思路:
首先,拿三个信息
- slide-list boundingClientRect
- slide boundingClientRect
- view index
然后模拟计算出这个 view index 内会出现哪些 slides,然后拿最高的 slide 就可以了。
有三个 options 会影响到 slides in view -- containScroll,slidesToScroll,align,特别讲一下 containScroll
const sliderOptions: EmblaOptionsType = { containScroll: 'trimSnaps', };
有三个值可以放,默认是 'trimSnaps',另外一个 'keepSnaps',还有一个是 false。
我不清楚 'keepSnaps' 和 'trimSnaps' 有什么区别 (没找到文档,看源码有点昏),但我知道 trimSnaps 和 false 在体验上有区别。
上述例子有 5 个 slides,每一个 view 可以显示两个 slides,一共有三个 views。
关键在第三个 view 长什么样
containScroll: false 长这样
因为有三个 view,每个 view 显示两个 slides,那最后一个 view 理应显示第 5 和第 6 个 slide。
不过我们只有 5 个 slides,所以第 6 个 slide 的位置就留空了。
containScroll: 'trimSnaps' 长这样
它不会留空,第三个 view 会显示第 4 和第 5 个 slide。
题外话:
我在 Swiper 文章里有提到一个问题 -- Auto Height and Same Height。
在 Embla 也会遇到相同的问题,我们可以用同样的解决方案,只不过那个方案依赖 slides in view,
放过来 Embla 的话,要嘛我们自己计算 slides in view,要嘛学 Auto Height Plugin 用 slideRegistry 就好。
Handle content resize
在没有 Auto Height 的情况下,slider 的高度是 hug content (依据 slide 的高度),假如 slide 的内容增加了,那 slider 的高度也会跟着增加。
在有 Auto Height 的情况下就不是这样,Auto Height 会给 slide-list 添加 height 固定它的高度,这会导致 slider hug content 失效。
当 slide 内容增加后,由于 slide-list 限高了,所以它只会被 overflow hidden,slider 高度不会跟着变高。
要解决这个问题,我们需要监听 slide 高度变更,然后通知 Auto Height,让它重新计算去 update slide-list 的 height。
看例子:
加一个 more content 和 read more button
点击 button 显示 more content
const readMoreBtn = document.querySelector<HTMLElement>('.read-more-btn')!; readMoreBtn.addEventListener('click', () => { const moreContent = document.querySelector<HTMLElement>('.more-content')!; moreContent.style.display = 'revert'; });
效果
点击后完全没有反应,因为 Auto Height 破坏了原本的 slider hug content。
相关 Issue – Auto Height and slide changing height
作者给的解方是在 resize 后调用 EmblaCarousel.reInit 方法
readMoreBtn.addEventListener('click', () => { const moreContent = document.querySelector<HTMLElement>('.more-content')!; moreContent.style.display = 'revert'; slider.reInit(); // resize 后调用 reInit 方法通知 Auto Height Plugin });
这样就行了。(注:感觉有点小题大做,但也没有其它管道了,或许作者是想统一接口吧)
效果
题外话:
Embla 内部是有监听 slider 和 slides resize 的
每当 resize 它就会调用 reInit,但是它监听的是 width,而不是 height。
连续 Next 体验问题
这个问题我在 Swiper 那篇也有提过。
auto height 每次换 slide 时都会改变 slider 高度,如果 navigation / pagination button 依赖这个高度,那体验就会被影响。
上面例子中,我们无法连续按 next button,因为它会跳上跳下。
解决思路有两个方向。
第一,navigation button 不要依赖 slide 的高度,比如我们把它从 slider 下面移到 slider 左边。(但有时候空间太少,真的没有地方可以放)
第二,让这个 auto height 慢一点触发,比如 next 了一秒后才 update height。
for 第二个方向,我们可以这样写
function updateHeight() { let slidesInView = slider.slidesInView(); if (slidesInView.length === 0) { // init or reInit 时 slidesInView 可能是 empty array slidesInView = slider.internalEngine().slideRegistry[slider.selectedScrollSnap()]; } const slideRects = slider.internalEngine().slideRects.filter((_, index) => slidesInView.includes(index)); const height = Math.max(...slideRects.map(rect => rect.height)); slider.containerNode().style.height = `${height}px`; } slider.on('init', updateHeight); slider.on('reInit', updateHeight); slider.on('settle', updateHeight);
不需要使用 Auto Height Plugin,单纯 Embla 底层 API 就可以了。(其实 Auto Height Plugin 内部也是调用这几个 API 实现的)
settle 事件会在 slide moving transition 结束后触发,非常非常的晚。
Add / Remove / Sort Slides
没有 add / remove / sort 接口,我们要增加 / 减少 / 改 slide 的位置的话,直接 DOM manipulation 就好。
DOM manipulation 完后调用 EmblaCarousel.reInit() 就可以了。
总之,它就只有一个接口,不管是 change options, change plugin, change size, change elements 都是调用 reInit 就对了。
CSS 优化手法
参考官网的 example,我们会看到几个 CSS 优化手法。
HTML 结构长这样
CSS
touch-action 是告诉游览器,它只负责 pan-y (vertical scroll) 和 pinch-zoom (scale 放大) 就好,其它手势交给我们负责。
transform: translate3d(0, 0, 0); 是让游览器使用 GPU 来渲染每个 slide。
embla__container 肯定会使用 GPU 渲染,因为它负责 transform 嘛,slides 则不会,所以要快就要特别声明。
touch-action: manipulation 是告诉游览器,这个 button 只需要最基本的 tap,不需要其它手势。
Embla Carousel 的其中一个卖点就是快,所以它的 example 尽可能优化到极致。
但我们一般上不需要跟着这么做,性能优化请等到用户有感觉到慢了才做。
当 Emble Carousel 遇上 YouTube Iframe
和 Swiper 一模一样的问题,解决方法也一模一样,在 Swiper 那篇已经讲解过了,这里就不复述了。
Navigation Plugin
上面虽然教了如何实现 Navigation,但手法过于粗糙,只能作为教材,还无法用于实战项目。
在真实项目中,我们会把它封装进一个 plugin 里,然后像 Autoplay 那样去使用它。
封装成 plugin 有两个好处:
-
plug & play
navigation 不是必须的,前端嘛,没有用到就尽量 tree-shake,让项目体积小一点。
-
统一支持 breakpoints 和 active
Embla 要求所有的 plugin 都要实现统一接口,支持 breakpoints options 和 active inactive 功能。
总之,即便只是为了顺风水,我们也该尽量把功能封装成 plugin 就对了。
好,这里我给一个 Navigation Plugin 的简单示范。
HTML
<div class="slider"> <div class="slide-list"> <div class="slide"> <img src="../images/yangmi1.jpg" alt="yangmi"> </div> <div class="slide"> <img src="../images/tifa.webp" alt="tifa"> </div> <div class="slide"> <img src="../images/nana.jpg" alt="nana"> </div> </div> <button class="prev-btn"> <svg class="icon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M41.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 256 246.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg> </button> <button class="next-btn"> <svg class="icon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M278.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-160 160c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L210.7 256 73.4 118.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l160 160z"/></svg> </button> </div>
关键是多了两个 navigation button。
Styles

.slider { max-width: 512px; overflow: hidden; position: relative; .slide-list { display: flex; .slide { flex-shrink: 0; width: 100%; img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; } } } .prev-btn, .next-btn { border-width: 0; cursor: pointer; position: absolute; top: 50%; transform: translateY(-50%); background-color: pink; color: red; border-radius: 999px; display: block; width: 40px; height: 40px; visibility: hidden; opacity: 0; transition-property: visibility, opacity; transition-duration: 0.4s; .icon { width: 20px; height: 20px; } } .prev-btn { left: 8px; } .next-btn { right: 8px; } &:hover { .prev-btn, .next-btn { &:not([disabled]) { visibility: unset; opacity: unset; } } } }
没什么,就只是一些美观的 styling 而已。
Embla 不掺和 HTML 和 CSS,所以上面这两部分都不算 plugin 封装。
目前效果
有 styles 了,但没有 script,还不能交互。
Scripts
创建一个 file -- slider-navigation-plugin.ts
我先讲解一个通用的 plugin 轮廓。
定义 Options
import { CreateOptionsType } from 'embla-carousel'; type Options = CreateOptionsType<{ prevBtn: HTMLButtonElement; nextBtn: HTMLButtonElement; }>;
options 就是 plugin 和项目沟通的管道。
navigation button element 是项目定义的,但 plugin 需要给 button 绑定事件,所以 plugin 需要认识 button,这时就要靠 options 把 button 传进来。
CreateOptionsType 是 Embla 内置的 TypeScript 方法,它的效果是这样
active 和 breakpoints 是所有 plugin options 必备的属性,prevBtn 和 nextBtn 则是 Navigation Plugin 独有的。
定义 Plugin 对象
export type SliderNavigationPlugin = CreatePluginType< { value: string; doSomething: () => void; }, Options >;
每个 plugin 创建后都是一个对象,除了 Embla 可以用,项目也可以用。
所有 plugin 对象都要有 name, options, init, destroy 属性和方法,这些都是 Embla 需要用到的,另外我们还可以定义其它属性方法让我们自己用,比如上图里的 value 和 doSomething。
如果没有额外的属性方法,定义的时候放一个空对象即可
export type SliderNavigationPlugin = CreatePluginType<Record<string, unknown>, Options>;
定义 create plugin 函数
轮廓长这样
export function createSliderNavigationPlugin(userOptions: PartialOptions): SliderNavigationPlugin { // 我习惯用 RxJS const destroySubject = new Subject<void>(); function init(slider: EmblaCarouselType, optionsHandler: OptionsHandlerType) { // 关键代码都在这里... } function destroy() { destroySubject.next(); } return { name: 'navigation', options: userOptions, init, destroy, }; }
每当 Embla init 或 reInit,destroy 方法会被执行,接着如果 Embla 是 active 同时 plugin 也是 active (depend on breakpoints options) 的话,init 方法会被执行。
我们在 init 方法里做事件绑定,在 destroy 方法里做解绑。
init 方法会被 Embla 调用,调用的时候会传入两个参数,第一个是 EmblaCarousel 对象,第二个是 OptionsHandler 对象。
OptionsHandler 对象有 2 个方法
-
mergeOptions
简单说就是一个 deep merge,支持 nested object merge。
-
optionsAtMedia
依据当前的 viewport media query 匹配出对应的 options
于是,我们可以这么写
type DefaultOptions = Omit<Options, OptionsRequiredKeys>; function init(slider: EmblaCarouselType, optionsHandler: OptionsHandlerType) { const { mergeOptions, optionsAtMedia } = optionsHandler; const defaultOptions: DefaultOptions = { active: true, breakpoints: {}, }; const mergedOptions = mergeOptions(defaultOptions, userOptions) as Options; const options = optionsAtMedia(mergedOptions); const { prevBtn, nextBtn } = options; console.log('bind event to prevBtn, nextBtn', [prevBtn, nextBtn]); }
把 options merge 一 merge,提取出 prevBtn 和 nextBtn,接着做 binding。
const stateChangeEventNames: EmblaEventType[] = ['select', 'init', 'reInit']; merge(...stateChangeEventNames.map(eventName => fromEvent(slider, eventName))) .pipe(takeUntil(destroySubject)) .subscribe(() => { prevBtn.disabled = !slider.canScrollPrev(); nextBtn.disabled = !slider.canScrollNext(); }); for (const button of [prevBtn, nextBtn]) { fromEvent(button, 'click') .pipe(takeUntil(destroySubject)) .subscribe(() => slider[`scroll${button === prevBtn ? 'Prev' : 'Next'}`]());
我习惯写 RxJS,看不懂的请让 ChatGPT / DeepSeek 为你讲解哦。
使用 plugin
用法和 Autoplay Plugin 大同小异。
const sliderElement = document.querySelector<HTMLElement>('.slider')!; const prevBtn = sliderElement.querySelector<HTMLButtonElement>('.prev-btn')!; const nextBtn = sliderElement.querySelector<HTMLButtonElement>('.next-btn')!; const navigationPlugin = createSliderNavigationPlugin({ prevBtn, nextBtn, // 可以设置 breakpoint breakpoints: { '(min-width: 1920px)': { active: false, // 可以 inactive }, }, }); const slider = emblaCarousel(sliderElement, undefined, [navigationPlugin]);
效果
卡卡问题
注意看最后几幕会卡卡的,因为 button click 和 slider swipe 打架了。
我们来解决它,首先加一个 options 属性
因为 plugin 不晓得 navigation button 是否在 slider 里,只有在 slider 里才会有打架的问题,才需要处理。
接着在 init 方法里加上这些代码
if (buttonInsideSlider) { merge(...['touchstart', 'mousedown'].map(eventName => fromEvent(slider.rootNode(), eventName, { capture: true }))) .pipe( filter(e => [prevBtn, nextBtn].some(navBtn => navBtn.contains(e.target as HTMLElement))), takeUntil(destroySubject), ) .subscribe(e => e.stopPropagation()); }
做了一个 stopPropagation 阻止 swipe,这样就不打架了。
Plugin event & instace
项目要操作 plugin instance (比如 call method 和 addEventListener) 需要这样写:
首先定义 plugin 和 事件类型
declare module 'embla-carousel' { interface EmblaPluginsType { navigation: SliderNavigationPlugin; } interface EmblaEventListType { navigationClick: 'navigation:click'; } }
定义了之后,我们就可以这样去使用它。
const navigation = slider.plugins().navigation;
slider.on('navigation:click', () => console.log());
在 init 方法内发布事件
注:好像是无法 passing event data 的,呃...这么瞎的吗🤔?
总结
本篇简单的介绍了 Slider Library 的明日之星 – Embal Carousel。
希望它赶快取代 Swiper,不然我写这篇干嘛呢...😊