核心指南
@reelkit/core 包提供与框架无关的滑动逻辑。用它来做自定义集成,或者理解底层架构。
架构概览
核心采用工厂函数式的控制器模式。没有类 —— 全是闭包返回的普通对象,零依赖。核心负责协调这几个部分:
- SliderController —— 中央状态管理与导航
- GestureController —— 触摸 / 指针拖拽处理
- KeyboardController —— 方向键与 Escape
- WheelController —— 带防抖的鼠标滚轮
createSliderController
创建一个新的滑动控制器实例,管理滑动器的全部状态和行为。
import { createSliderController } from '@reelkit/core';
const controller = createSliderController(
{
count: 10,
direction: 'vertical',
enableWheel: true,
transitionDuration: 300,
},
{
onAfterChange: (index) => console.log('Changed to:', index),
}
);
// Attach to DOM element
controller.attach(element);
controller.observe();控制器方法
导航
// Go to specific index
controller.goTo(5); // instant
controller.goTo(5, true); // animated, returns Promise
// Navigate to next/previous
controller.next();
controller.prev();生命周期
// Connect to DOM element
controller.attach(element);
// Start gesture, keyboard, and wheel observation
controller.observe();
// Stop gesture, keyboard, and wheel observation
controller.unobserve();
// Detach DOM listeners (reversible — use for React effect cleanup)
controller.detach();
// Permanent teardown (use for Angular onDestroy)
controller.dispose();
// Recalculate positions
controller.adjust();
// Update size
controller.setPrimarySize(600);状态更新
// Update configuration
controller.updateConfig({
count: 20,
loop: true,
});虚拟化
核心在任何时刻只往 DOM 里渲染 3 张幻灯片(当前、上一张、下一张)。由范围提取器决定渲染窗口里包含哪些索引:
import { defaultRangeExtractor } from '@reelkit/core';
// Default: renders current ± 1 (3 DOM nodes)
const indexes = defaultRangeExtractor(currentIndex, count);
// Custom: skip hidden slides by shifting to next valid index
const hiddenSlides = new Set([2, 5]);
const skipHiddenExtractor = (current: number, count: number) => {
const result: number[] = [];
// Collect prev, current, next — skip hidden, shift forward
for (let i = current - 1, added = 0; added < 3 && i < count; i++) {
if (i >= 0 && !hiddenSlides.has(i)) {
result.push(i);
added++;
}
}
return result;
};结果始终被限制在最多 3 个索引。如果你的提取器返回更多, 核心会保留围绕当前幻灯片居中的那 3 个。
Signals
核心使用一套轻量的信号系统来做响应式:
import { createSignal, createComputed, reaction } from '@reelkit/core';
// Create a signal
const count = createSignal(0);
// Observe changes (returns a disposer function)
const dispose = count.observe(() => console.log(count.value));
// Update value
count.value = 5;
// Create computed signal (requires a deps factory)
const doubled = createComputed(() => count.value * 2, () => [count]);
// Run side effects on signal changes
const disposeReaction = reaction(
() => [count],
() => console.log('Count changed:', count.value)
);
// Cleanup
dispose();
disposeReaction();控制器状态
通过 controller.state 访问响应式状态:
const { index, axisValue, indexes } = controller.state;
// Observe index changes (returns a disposer function)
const disposeIndex = index.observe(() => {
console.log('Current index:', index.value);
});
// Observe visible indexes for virtualization
const disposeIndexes = indexes.observe(() => {
console.log('Visible:', indexes.value);
});
// Cleanup when done
disposeIndex();
disposeIndexes();时间轴控制器
为任意 <video> 元素做一个自定义拖动条。控制器把时长、当前时间、缓冲区间和拖动状态 都暴露成响应式信号,并且一次调用就能把指针和键盘交互接到任意 DOM 元素上。
import { createTimelineController } from '@reelkit/core';
const timeline = createTimelineController({
onScrubStart: () => video.pause(),
onScrubEnd: () => video.play(),
});
timeline.attach(video);
const dispose = timeline.bindInteractions(trackEl);
// Render: read signals and update DOM
timeline.progress.observe(() => {
fillEl.style.width = `${timeline.progress.value * 100}%`;
});
// Cleanup
dispose();
timeline.detach();URL 状态
把浮层的打开状态放进地址栏:当前这张幻灯片就有了可分享、可直达的链接, 按返回键即可关闭。模型由核心持有;各框架绑定把它包成一个钩子(React / Vue 的 useOverlayUrlState,Angular 的 createOverlayUrlState)以及一个由 URL 驱动的浮层组件。
工作原理
createUrlStateController 把一个查询参数映射成信号,并把变化写回去。打开时压入一条历史记录,之后每次导航都是替换 —— 滑一百次也不会多出一条,所以退一步永远就是关闭。 UrlAdapter 是可插拔的读写接缝:默认实现走 history.pushState;带路由的应用则传入一个基于路由器的适配器,这样路由器自己的 location 永远不会过期。
import { createUrlStateController, urlIndexKey } from '@reelkit/core';
const controller = createUrlStateController({
param: 'photo',
...urlIndexKey(() => items.length),
});
const detach = controller.attach(); // begin mirroring the URL
controller.position.observe(() => {
// null → closed; a number → open at that slide
render(controller.position.value);
});
// Write back: opening pushes once, navigating replaces, closing clears
controller.set(3);
controller.set(null);编解码器与定位器 —— 两件事
一个 key 是配套的 { codec, locator } 组合。把身份写进 URL 和在当前集合里找到它的位置,是两件不同的事, 所以它们是两个对象:
- codec —— 传输格式。
encode把身份写成参数文本;decode再解析回来,遇到非法值直接拒绝,参数就会自动从 URL 里消失。 - locator —— 查找。
locate在当前集合里找到解码后的身份所在的位置(找不到就返回null);identify则把位置反查回身份,用于写入;可选的locateAsync会在未命中时继续翻页加载窗口式或无限式的信息流。
把两者分开,你就能任意搭配 —— 比如稳定 id 的编解码器配上一个会翻页的定位器。
索引 key 与稳定 id key
有两个内置 key 会帮你搭好这一对,区别只在于 URL 里写的是什么:
urlIndexKey(() => count)按位置寻址(?photo=3)。最简单,但列表一旦重新排序,书签打开的就是另一条了。urlStableIdKey({ items })按每个条目稳定的id寻址(?photo=post_42),扫描当前列表 —— 重新排序后书签指向的仍是那条内容,条目没了就干净地失效。hashCodec: base64UrlCodec会把 id 做 base64url 混淆(可逆,不是加密哈希)。
要给窗口式信息流翻页? 两个内置 key 都接受可选的 locateAsync—— urlIndexKey(() => count, locateAsync) 和 urlStableIdKey({ items, locateAsync })。同步查找负责已加载的部分;未命中时继续把剩下的翻进来, 于是指向窗口之外的分享链接照样能打开 —— 不需要你自己手写编解码器或定位器。
需要两个维度? urlIndexTwoAxisKey 会携带 ?p=<outer>.<inner>,同时表示一条帖子和它内部的媒体索引。完整选项见 核心 API 参考。
已观看状态
记住观看者在图库中看到了哪里,跨刷新、跨标签页——一个显示已看内容的圆环,一个从上次离开处重新打开的图库。它与 URL 状态是同一套模型,只是指向存储而不是地址栏,因此两者共用一个键。
工作原理
createViewedStateController 把记录存为 ?photo= 链接所携带的原文,并通过同一个 decode → locate 循环读回。已存的位置从不被直接信任。把同一个键展开到两处,书签和已存记录就是同一个字符串。
import {
createUrlStateController,
createViewedStateController,
urlStableIdTwoAxisKey,
twoAxisViewedTracking,
} from '@reelkit/core';
const key = urlStableIdTwoAxisKey({ outerItems, innerItems });
const url = createUrlStateController({ param: 'story', ...key });
const seen = createViewedStateController({
storageKey: 'stories-seen',
...key,
...twoAxisViewedTracking,
});
seen.attach(); // reads storage, follows other tabs
seen.record({ outer: 2, inner: 1 }); // furthest point wins, a rewatch never rewinds
seen.resolve('user_42'); // → { outer: 2, inner: 1 } | null持久性取决于键
存储本身不做任何修复,所以你展开哪个键,决定了什么能保留:按 id 寻址的键在集合重排后仍记得位置,按位置寻址的键则不能。已存记录记的是到达的最远点,而不是观看次数,因此从中间删掉一项会让计数变短——与分享链接得到的自愈相同。
读取只有同步方式。条目尚未加载的记录会读作缺失,并原样留在存储中,因此分窗加载的信息流永远不会吞掉自己的历史。
存储与过期
存储默认由 localStorage 支撑;createSessionStorageAdapter() 在关闭标签页时遗忘,自定义 StorageAdapter 则可以放到任何同步的地方。在 attach() 之前不会读取任何内容,因此服务端渲染与首次客户端渲染保持一致。
记录会一直保留,直到被显式遗忘。传入 ttlMs 让它们过期——按轨道、按滑动时钟计算,因此仍在观看的内容不会与早已放弃的内容一起过时。它会改变写入的内容,每条记录变成一对 [wire, timestamp],但无论选项如何设置,读取都能接受两种形状,所以在开启之前存下的记录会被视为新鲜,而不是被删除。maxTracks 限制的是数量而非时长:超出后,最久未记录的轨道会在下次写入时被丢弃,而记录某条轨道(即使位置落后)会把它移到队尾。完整选项见 核心 API 参考。
自动播放
为一切自动前进的内容计时。Stories Player 用 createTimerController 驱动每条 story;Lightbox 用建立在它之上的 createSlideshowController 驱动幻灯片播放。两者都基于 requestAnimationFrame 推进,每次暂停都会保留精确的进度。
createTimerController
把一段时长从 0 数到 1,结束时调用回调。start() 总是从 0 开始,也可以为这一次传入另一段时长;pause() 和 resume() 会保留精确的位置。
import { createTimerController, reaction } from '@reelkit/core';
const timer = createTimerController({
duration: 5000,
onComplete: () => console.log('Timer finished!'),
});
// Observe progress (0 to 1)
const dispose = reaction(
() => [timer.progress],
() => {
console.log('Progress:', timer.progress.value);
},
);
// Start with default duration
timer.start();
// Or override duration for a specific story
timer.start(8000);
// Pause/resume preserves exact position
timer.pause();
timer.resume();
// Reset to 0
timer.reset();
// Cleanup
dispose();
timer.dispose();createSlideshowController
自动前进的图库背后的状态机。只有在开启、没有被任何原因按住、并且页面可见时才会运行;页面可见性由它自己监听,其余的由你告诉它。图片从加载完成那一刻开始计算间隔,所以加载慢的图片不会在没人看到时被跳过。视频在结束时前进,所以它必须只播放一次而不是循环。在幻灯片被按住期间结束的视频,会在释放后前进。
import { createSlideshowController, createSliderController } from '@reelkit/core';
const items = [{ type: 'image' }, { type: 'video' }, { type: 'image' }];
const slider = createSliderController(
{ count: items.length, direction: 'horizontal' },
{ onAfterChange: (index) => onSlideChange(index) },
);
const slideshow = createSlideshowController({
interval: 5000,
onAdvance: () => {
if (slider.state.index.value >= items.length - 1) slideshow.pause();
else slider.next();
},
});
// Every slide change starts over and waits for the new slide.
function onSlideChange(index: number) {
slideshow.setSlide(items[index].type === 'video' ? 'video' : 'image');
}
// From the slide: an image counts its interval once it loaded or failed,
// a video advances when it ends (let it play once rather than loop).
const onImageSettled = () => slideshow.ready();
const onVideoEnded = () => slideshow.ended();
// Holds stack: the slideshow runs again once every reason is released.
const onControlsEnter = () => slideshow.hold('hover');
const onControlsLeave = () => slideshow.release('hover');
onSlideChange(0);
slideshow.play();
// Later
slideshow.dispose();完整的配置、状态和方法见 Core API 参考。
下载
downloadFile(url, filename?) 以 filename 为名保存文件,默认是 URL 路径的最后一段。跨源链接会忽略 download 属性,所以它会用 fetch 获取文件并保存 blob。当 fetch 失败或服务器返回错误时(最常见的是没有 CORS 头的跨源服务器),URL 会在新标签页中打开,读者仍然能拿到文件。它从不 reject,没有 window 时什么也不做。参见 Core API 参考。
下一步
- 核心 API 参考 - 全部可用配置项
- 框架指南 - 组件、演示与集成方式框架指南 - 组件、演示与集成方式框架指南 - 组件、演示与集成方式
- Reel Player - TikTok / Reels 风格的视频播放器Reel Player - TikTok / Reels 风格的视频播 放器Reel Player - TikTok / Reels 风格的视频播放器
- Lightbox - 图片与视频画廊Lightbox - 图片与视频画廊Lightbox - 图片与视频画廊
- Stories Player - Instagram 风格的 Stories 浏览器Stories Player - Instagram 风格的 Stories 浏览器Stories Player - Instagram 风格的 Stories 浏览器