Elk — Mastodon 客户端

为了验证 Vue Lynx 能承载真正产品级的应用,我们把 Anthony Fu 团队广受喜爱的 Mastodon Web 客户端 Elk 移植成了一个 原生 Mastodon 客户端。它以游客身份浏览任意公开 Mastodon 实例 (默认 mas.to):时间线、会话串、个人主页、搜索、趋势、深色模式等。

在下方直接体验。Web 标签页通过 Lynx for Web 连接线上实例运行真实应用; 二维码 标签页原生运行同一份 main.lynx.bundle——用 Lynx Go / Lynx Explorer 扫码即可(安装见快速开始)。

原生 viewpager 变体 需要较新的 Lynx 引擎

elk-viewpager 分叉用原生 <viewpager> 元件承载 Explore 与通知页的标签页,取代按条件渲染的面板。在 TabPager.vue 中,每个面板都是一个 <viewpager-item>:可横向滑动翻页、带原生吸附动画, 滑走再滑回时内容与滚动位置都保留,标签栏与翻页器双向同步——滑动触发翻页器的 change 事件(→ 当前标签),点按标签调用其 selectTab 方法(→ 动画翻到 该页)。体验更接近原生客户端。

<viewpager> 是一个 Lynx XElement, 在不同平台注册为不同标签名,因此 TabPager.vue 在运行时根据 SystemInfo.platform 选择标签名(下方高亮处):Lynx for Web 用 <x-viewpager-ng>,原生 OSS 引擎用抽取出来的 <viewpager> / <viewpager-item>

引擎版本依赖

抽取出来的 <viewpager> 是在 lynx-family/lynx c1d8d7920(2026-04)才进入 OSS 引擎的——比任何已发布的 LynxExplorer 都新。3.8.1 及更早版本既没有 <viewpager> 也没有 <x-viewpager-ng>,标签区域会渲染为空白(并伴随 LynxCreateUIException)。请在从 lynx develop 构建的宿主上运行该变体; 在已发布的 Explorer 上,请用上方的默认 Elk 示例——它按条件渲染的标签无需 pager 元件。

换成原生 pager,到底要改多少代码?

几乎不用。把整个应用切到原生 pager 只动了 三个文件:新增的 TabPager.vue,以及承载可滑动标签页的两个页面(ExplorePage.vueNotificationsPage.vue)。其余约 55 个源文件——masto.js 客户端、内容 渲染器、路由、虚拟化 <list>——逐字节完全一致。

在调用处就是一次直接替换。原来的手写标签栏加 v-if / v-else-if 面板, 任一时刻只有当前面板存在:

<!-- elk:一次只渲染一个面板 -->
<view class="explore-tabs">
  <view class="explore-tab" @tap="tab = 'posts'">…</view>
  <view class="explore-tab" @tap="tab = 'tags'">…</view>
</view>
<list v-if="tab === 'posts'" @scrolltolower="loadNext">…</list>
<list v-else-if="tab === 'tags'" @scrolltolower="loadMoreTags">…</list>

……换成一个「每个面板一个具名 slot」的组件:

<!-- elk-viewpager:所有面板同时存活、可滑动 -->
<TabPager v-model="tab" :tabs="tabs">
  <template #posts>…</template>
  <template #tags>…</template>
</TabPager>

UI 侧的全部改动就是这次 v-if → slot 重写——标签栏、下划线动画、 pager ↔ 标签双向同步,统统搬进 TabPager

只有一处不是机械替换,它恰好把「原生翻页」的本质讲清楚了。用 v-if 时 只有可见面板存在,一个共享 paginator 就够了。换成 pager 后 所有面板同时 挂载、可来回滑动——于是每个标签各自保留数据和滚动位置。通知页因此从 「一个共享 feed」改成「每标签一份」:

- let pager = signedIn ? makePaginator() : empty;
- const items = ref([]);          // 单个活动 feed,切标签即重置
+ const feeds = reactive({         // 每个面板一份 feed,滑动之间保留
+   all:     { items: [], state: 'idle' },
+   mention: { items: [], state: 'idle' },
+ });

这就是取舍:你放弃「只渲染可见内容」,换来面板始终「热着」——滑走再滑回, 还停在你离开的位置,无需重新加载。

更进一步:会折叠的个人主页

个人主页把这套模式又往「原生 profile」推进了一步。 AccountPage 把同一个 viewpager 包进了 Lynx 的折叠头部协调器 (StickyTabView.vue): 向下滚动时,banner/简介/统计构成的头部折叠收起标签栏吸顶, 下面的 Posts / Replies / Media 面板继续横向翻页,各自保留自己的 feed 和滚动位置。协调器按「头部(折叠)+ 工具栏(吸顶标签)+ 插槽(viewpager)」 堆叠,其嵌套滚动会先折叠头部,再把滚动交给当前面板的列表。和 viewpager 一样,它按平台注册不同标签名——Lynx for Web 用旧版 <x-foldview-ng>, 原生 OSS 引擎用抽取出来的 <scroll-coordinator>

原生 Feed 技巧

Mastodon 客户端几乎全是长列表:主页、本地、联邦、探索、通知、搜索、个人帖子、关注者。Web 版 Elk 用 virtua 做虚拟列表,再用 DOM 末端锚点的 bounding box 触发下一页。Lynx 提供两种滚动原语——选哪一个,就是整段性能故事。

<scroll-view> 会挂载全部子节点

<scroll-view> 适合短而异构的页面:设置、发帖、帖子详情、媒体 sheet。每个子节点都会挂载,你滚的是普通布局树。这也是它不适合时间线的原因——联邦时间线一旦超过几十条嘟文,视口上下每一张卡片都在付账。

<list> 负责复用与分页

<list> 是 Lynx 的复用型滚动器。离屏的 <list-item> 单元格会被回收;你只保留一扇真实节点窗口。无限滚动是原生事件,不是几何轮询:

<list
  scroll-orientation="vertical"
  :lower-threshold-item-count="4"
  @scrolltolower="loadNext"
>
  <list-item
    v-for="status in items"
    :key="status.id"
    :item-key="status.id"
    :estimated-main-axis-size-px="160"
  >
    <StatusCard :status="status" />
  </list-item>
</list>

三个属性扛起重活:

部件作用
estimated-main-axis-size-px在每个单元格实测之前,先估出可滚动范围
lower-threshold-item-count距末尾还有多少项时开始请求下一页
@scrolltolower触发 usePaginator.loadNext() —— Elk 的 masto.js Paginator 迭代逻辑原样保留

TimelinePaginator.vue 是共享的 feed 外壳:首屏、错误重试、复用型列表,以及页脚 spinner / 「时间线到底了」。时间线、Explore 帖子、话题页、书签/收藏,以及个人主页的 Posts / Replies / Media 都复用它。Explore 标签/新闻、通知、关注者、搜索则把同一套 <list> + scrolltolower 接到各自的条目模板上。

<scroll-view> 仍用在复用帮不上忙的地方——帖子详情、设置表单、发帖 sheet。移植最终定下的经验法则是:会无限变长的用 <list>;有限页面继续用 <scroll-view>

为什么 Elk 是一块试金石

Elk 是一个约 196 个组件、55 个页面、50 个 composable 的 Nuxt 3 应用。 Vue Lynx 没有 Nuxt(没有 SSR、文件路由、Nitro、自动导入),也没有 DOM —— 所以移植没有走 fork 路线,而是复用 Elk 与框架无关的层,在 Lynx 元件上重建 UI

结论说明
masto.js API 客户端✅ 原样复用将包装器注入的原生 fetch 与 Web 端的 globalThis.fetch 同步;其余构造器通过定向 source.define 和按需原生补丁兼容
内容解析管线content-parse.ts✅ 约 95% 原样ultrahtml 消毒 + 自定义表情 / markdown / 提及折叠等变换
内容渲染器content-render.ts♻️ 重定向输出AST 遍历逻辑不变;产出从 <p>/<a>/RouterLink 换成带点按导航的 <text>/<image>
分页器、时间线过滤、状态操作、搜索、缓存✅ 复用DOM 滚动触发 → 原生 <list>scrolltolower
虚拟滚动♻️ 替换Elk 的 DOM 虚拟滚动库(virtua)→ Lynx 原生复用型 <list>,代码反而更少
路由♻️ 重建Nuxt 文件路由 → 基于 createMemoryHistory 的显式 vue-router 路由表,路径形状保持一致,内容渲染器的提及/话题改写无需变更
所有模板♻️ 重建<div><view><span><text>@click@tap,Elk 的主题色板原样搬为 Lynx CSS 变量
图标♻️ 适配Elk 的 RemixIcon 图标集(i-ri:*)以着色 XML 交给 Lynx 内置 <svg content> 元件渲染
原生安全区✅ 适配iOS 全屏卡片读取 Sparkling 的 topHeight / bottomHeight global props,并兼容 Lynx Explorer 别名

完整的功能对齐清单(包括刻意未移植的项与原因 —— OAuth 重定向、TipTap、 PWA、Shiki、blurhash 等)见 PRD.md, 架构对照见 PORTING.md, 与原版 elk.zone 的逐屏截图对比见 screenshots/

亮点

  • 内容渲染器是皇冠明珠:Mastodon 的嘟文以消毒后的 HTML 下发。Elk 把它解析成 AST 再渲染为 vnode;移植保持解析步骤逐字节不变,只替换 vnode 目标 —— 自定义表情变为行内 <image>,提及/话题变为可点按的 <text>,直接推入 vue-router 路由。
  • 原生虚拟化时间线:feed 使用带 estimated-main-axis-size-px@scrolltolower 的 Lynx <list>(而不是 <scroll-view>);masto.js Paginator 迭代与 Elk 的排序/缓冲逻辑原样驱动时间线、探索、个人主页与搜索的 loadNext()
  • 深链:向 LynxView 传入 globalProps: { initialPath: '/mas.to/tags/caturday' } 即可让应用直接打开对应路由 —— 宿主应用处理通知点击时用的就是同一机制。
  • 游客 + 令牌会话:像 Elk 游客模式一样匿名浏览,或在设置中粘贴个人访问令牌, 解锁主页时间线、通知、发帖、转发与喜欢。