JavaScript Library – Embla Carousel

前言

2022 年 4 月,我写了一篇 Swiper 介绍。

Swiper 是当时前端最多人使用的 Slider 库,没有之一,一骑绝尘。

但是!时过境迁,这两年已经有一匹神秘的黑马悄悄杀上来了。

它就是本篇的主角 -- Embla Carousel

Embla Carousel (简称 Embla) 何德何能?它凭什么在 Swiper 垄断的市场里能杀出一条血路🤔?

  1. lightweight

    Embla 最大的卖点是 lightweight。

    据说它非常非常轻,且性能非常好。

    p.s.:具体多轻我不清楚,但肯定比 Swiper 轻很多,我就是嫌 Swiper 又重又慢,才在 research 替代方案时找到了 Embla。

  2. framework integration

    Embla 可以很容易得集成到各种前端框架,比如:React/Next.js,Svelte,Vue,Solid.js 等 (哎哟,不错哦,没有 Angular)。

  3. Customization and independent of CSS

    Embla 不掺和 styling CSS,它只负责 JS 逻辑,并且开放底层 API 接口,让使用者可以根据自己项目需求订做专属的 Carousel (a.k.a Slider)。

以上三点无疑是近几年前端的趋势和刚需,雷军说过,站在风口上,猪也能飞,Embla 在这里做了最好的示范👍。

想从 Swiper 直接切换到 Embla Carousel 并不容易,因为 Embla 比 Swiper low level,我们需要自己补上许多上层的封装才行。

本篇我会把我使用到的 Swiper 范围 (这篇里的内容) 用 Embla 来实现一遍,大家可以感受一下它俩在使用上的区别。

注:本篇不会从 0 基础讲起,最好你使用过 Swiper 或者其它 Slider Library。

参考:

官网 -- Embla Carousel

安装

复制代码
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

到这里就已经可以跑起来了

参考:官网

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/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>
  <div class="navigation">
    <button class="prev"><</button>
    <button class="next">></button>
  </div>
</div>

View Code

增加一个 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;
      }
    }
  }
}

View Code

目前的效果

两个 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 directive = button === prevBtn ? 'Prev' : 'Next';
  // 监听 prev next button click
  // 当 user 点击后,调用 slider.scrollPrev() 或 scrollNext 方法来移动 slide
  button.addEventListener('click', () => slider[`scroll${directive}`]());
}

监听 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) 重置。

最终效果

looping 无限循环

Embla 支持 looping,配置 options 就可以了。

复制代码
const slider = emblaCarousel(sliderElement, { loop: true });

效果

超级丝滑...这个 slide 的体验秒杀 Swiper。

上一 part 我们提到的 canScrollPrev 和 canScrollNext 在 looping 的情况下,一定返回 true。

Autoplay

参考:官网

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 可以调

  1. autoPlay({ delay: 1000 })

    delay 多久后 auto swipe to next slide,默认是 4000 milliseconds。

  2. playOnInit

    是不是一开始就 start autoplay,默认是 true,如果我们想自己决定何时 start 那就 set to false,然后自己调用 API 让它 start,下面会教。

  3. stopOnFocusIn

    当 slider 内有任何 element 被 focused,autoplay 就会终止 (不是 pause,是 stop),默认是 true。

  4. stopOnMouseEnter

    mouse hover 到 slider,autoplay 就终止,默认是 false。

  5. stopOnInteraction

    interaction 指的是 slider 被 pointerdown,默认是 true。
    比如 swipe to next slide 这个交互就涉及到了 pointerdown,所以它会 stop autoplay。

    注:如果我们是透过 click navigation button 来 next slide,这可不会 stop autoplay 哦,因为 button 是在 slider 外面,click button 不会触发 slider 的 pointerdown。

  6. stopOnLastSnap

    autoplay 到最后一个 slide 就终止,默认是 false。

    如果没有设置 looping,在最后一个 slide 它依然会倒退回到第一个 slide。

  7. 默认 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

常用方法:

  1. autoplayPlugin.play();

    启动 timer,到点 auto swipe to next slide。

    如果我们配置 playOnInit: false,那 autoplay 就不会开启,我们需要手动调用 play 方法让它启动。

  2. stop()

    stop 就是完全停掉 autoplay,timer 会马上被 clear 掉。

    stop 了以后,可以用 play 让它恢复。

  3. isPlaying()

    返回 boolean,判断当前 autoplay 是否是启动状态。

  4. reset()

    reset 的意思是重算 timer。(注:autoplay 在启动状态下才能 reset 哦)

    比如说,timer delay 4 秒后会 auto swipe,当前是第二秒,我们执行 reset,那 timer 就重算,要再等 4 秒后才会 auto swipe。

  5. timeUntilNext()

    距离下一次 auto swipe 的时间,它返回的是 millisecond。

    比如说,timer delay 4 秒后会 auto swipe,当前是第三秒,我们执行 timeUntilNext 会得到 1000,代表 1 秒后会 auto swipe。

  6. init()

    所有 plugin 都必须实现 init 方法,这个是给 slider 初始化 plugin 时用的,我们一般不会直接调用它。

  7. destroy()

    所有 plugin 都必须实现 destroy 方法,这个是给 slider destroy 时用的,我们一般不会直接调用它。

  8. name

    每个 plugin 都有名字,autoplay plugin 的名字叫 'autoplay'。

  9. options

    这个 options 对象就是我们调用 autoplay 函数时传入的那个 options 对象。

    特别要留意的地方是,这个对象不包含 default options。

    比如说,传入的 options 对象是 { delay: 1000 },default options 是这样

    拿 autoplayPlugin.options.stopOnInteraction 将得到 undefined,而不是 true,因为 stopOnInteraction 是定义在 default options 里,而不是在我们传入的 options 对象里。

    我个人觉得它这样设计很不方便,应该要提供一个 merged options 给我们用才对。

常用事件:

  1. 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,它就会触发。

  2. 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 掉了。

  3. autoplay:select

    autoplay 开启后,会先 delay,等 timer 到点后,它会 auto swipe,这个 select event 就是在 auto swipe 的时候触发的。

  4. autoplay:timerset

    autoplay 的流程是 delay > swipe > delay > swipe,每一次 delay 都是用 setTimeout 完成的,每一次 set 这个 timer 都会 fire 'autoplay:timerset' event。

  5. 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,那它会使用回之前的 options 和 plugins。

最后的实现代码是这样

复制代码
window.setTimeout(() => {
  autoplayPlugin.options.delay = 4000; // 2 秒后修改 delay 从 1 秒变 4 秒
  slider.reInit();
}, 2000);

看到吗,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(),
  );

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 吃掉。

如果你觉得上面这种 overriding 的写法不太好理解的话,那可以改成下面这样

复制代码
.slide-list {
  --slides-per-view: 3;
  --slide-gap: 16px;

  display: grid;
  grid-auto-flow: column;
  grid-template-columns: calc((100% / var(--slides-per-view)) - var(--slide-gap)); /* for first slide width */
  grid-auto-columns: calc(100% / var(--slides-per-view));/* for other slide width */

  /* first slide 没有 padding-left */
  .slide &:not(:first-child) {
    padding-left: var(--slide-gap);
  }
}

首先 first slide 没有 padding-left。

这会导致 first slide 和其它 slides 的 width 不一致,我们需要另外处理 -- 用 grid-template-columns 来调整 first slide width,让它和其它 slides 保持一致。

最终效果

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。

接着监听 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 是从小(上)到大(下)。

效果

题外话:breakpoint change 导致 options change,Embla 底层会使用 reInit 方法重置,reInit 事件当然也会触发。

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 属性就可以了。

Pagination

Swiper 有 bulit-in 的 pagination,也支持 full custom pagination

Embla 没有 built-in 的 pagination,我们需要像 navigation 那样,使用 Embla 底层 API,自己写上层实现代码。

实现要点

paignation 长这样

下面一粒一粒的叫 bullet。

三个要点:

  1. 点击 bullet 会移动 slide

  2. active bullet

    active bullet 就是那颗比较亮的 bullet,slide 在第几个,active bullet 就要在第几个。

  3. 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>

View Code

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;
    }
  }
}

View Code

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

  1. EmblaCarousel.selectedScrollSnap 方法

    scroll snap 就是 view 的别名 (alias)。

    selectedScrollSnap 会返回当前 view index (当前在第几个 view)。

  2. 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>

View Code

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);
        }
      }
    }
  }
}

View Code

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);

View Code

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>

View Code

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;
      }
    }
  }
}

View Code

Scripts

复制代码
const sliderContainer = document.querySelector<HTMLElement>('.slider-container')!;
const sliderElement = sliderContainer.querySelector<HTMLElement>('.slider')!;

const sliderOptions: EmblaOptionsType = {
  align: 'start',
  slidesToScroll: 'auto',
  inViewThreshold: 0.1,
};
const slider = emblaCarousel(sliderElement, sliderOptions);

function updateHeight() {
  const viewIndex = slider.selectedScrollSnap();
  const slideList = slider.containerNode();
  const slideListElementRect = slideList.getBoundingClientRect();
  const slideRects = slider.slideNodes().map(slide => slide.getBoundingClientRect());
  const lastSlideRects = slideRects[slideRects.length - 1];

  let viewCoordinateXEnd = Math.round((viewIndex + 1) * slideListElementRect.width);
  if (sliderOptions.containScroll !== false) {
    const lastSlideCoordinateXEnd = Math.round(lastSlideRects.left - slideListElementRect.left + lastSlideRects.width);
    viewCoordinateXEnd = Math.min(viewCoordinateXEnd, lastSlideCoordinateXEnd);
  }
  const viewCoordinateX = {
    start: viewCoordinateXEnd - slideListElementRect.width + 1,
    end: viewCoordinateXEnd,
  };

  const intersectingSlides = slideRects.filter(rect => {
    const left = rect.left - slideListElementRect.left;
    const slideCoordinateX = {
      start: Math.round(left + 1),
      end: Math.round(left + rect.width),
    };
    const isIntersecting =
      (slideCoordinateX.start >= viewCoordinateX.start && slideCoordinateX.start <= viewCoordinateX.end) ||
      (slideCoordinateX.end >= viewCoordinateX.start && slideCoordinateX.end <= viewCoordinateX.end) ||
      (slideCoordinateX.start < viewCoordinateX.start && slideCoordinateX.end > viewCoordinateX.end);
    return isIntersecting;
  });

  const height = Math.max(...intersectingSlides.map(rect => rect.height));
  slideList.style.height = `${height}px`;
}

slider.on('init', updateHeight);
slider.on('reInit', updateHeight);
slider.on('select', updateHeight);

View Code

效果

和 Auto Heigh Plugin 的区别是在最后一个 view,它的第 4 个 slide 会被 overflow,我的不会。

我解释一下实现思路:

首先,拿三个信息

  1. slide-list boundingClientRect
  2. slide boundingClientRect
  3. view index

然后模拟计算出这个 view index 内会出现哪些 slides,然后拿最高的 slide 就可以了。

里面会出现一个程咬金 -- containScroll

它是一个 options

复制代码
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

假如我们 slide 里面有动态内容会导致 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 Plugin 默认是不会监听 resize 的。

相关 Issue -- Auto Height and slide changing height

作者给的解方是透过 EmblaCarousel.reInit 方法

复制代码
readMoreBtn.addEventListener('click', () => {
  const moreContent = document.querySelector<HTMLElement>('.more-content')!;
  moreContent.style.display = 'revert';

  slider.reInit(); // resize 后调用 reInit 方法通知 Auto Height Plugin
});

这样就行了。(note:感觉有点小题大做,但也没有其它管道了,或许作者是想统一接口,也可能 reInit 内部已经做了很多优化,可以不用担心性能问题)

效果

连续 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 尽可能优化到极致。

但我们一般上不需要跟着这么做,性能优化请等到用户有感觉到慢了才做。

和 Swiper 一模一样的问题,解决方法也一模一样,在 Swiper 那篇已经讲解过了,这里就不复述了。

总结

本篇简单的介绍了 Slider Library 的明日之星 -- Embal Carousel。

希望它赶快取代 Swiper,不然我写这篇干嘛呢...😊