教程:商品画廊

在本教程中,我们将使用 Vue 3 和 Lynx 一起构建一个商品画廊页面。本教程不需要任何 Lynx 的前置知识。你在本教程中学到的技术是构建任何 Lynx 页面和应用的基础。

Note

本教程专为喜欢在实践中学习、希望快速制作出有形成果的人设计。如果你更喜欢逐步学习每个概念,请从描述 UI 开始。

我们要构建什么?

一个家具画廊,拥有精美的瀑布流布局、点击收藏交互、自动滚动和自定义滚动条——由主线程脚本驱动,实现丝滑流畅的性能。每个章节都在前一个章节的基础上逐步构建。

教程准备工作

查看详细的快速开始文档,它将指导你创建一个新的 Lynx 项目。我们推荐使用 TypeScript 以获得更好的开发体验,通过静态类型检查和更好的编辑器 IntelliSense 来实现。

在本指南中你会看到很多精美的图片。我们准备了一个示例图片包,你可以在这里下载,用于你的项目中。

你的 lynx.config.ts 应该是这样的:

lynx.config.ts
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginVueLynx } from 'vue-lynx/plugin';

export default defineConfig({
  source: {
    entry: {
      // We'll add entries here as we go
      ImageCard: './src/gallery/ImageCard/index.ts',
    },
  },
  plugins: [
    pluginVueLynx({
      optionsApi: false,
      enableCSSSelector: true,
    }),
  ],
});

添加样式

由于本教程的重点不在于如何为 UI 添加样式,你可以节省一些时间,直接复制下面的 gallery.css 文件:

gallery.css
gallery.css
.gallery-wrapper {
  height: 100%;
  background-color: black;
}

.single-card {
  display: flex;
  align-items: center;
  justify-content: center;
}

.scrollbar {
  position: absolute;
  right: 7px;
  z-index: 1000;
  width: 4px;
  background: linear-gradient(to bottom, #ff6448, #ccddff, #3deae7);
  border-radius: 5px;
  overflow: hidden;
  box-shadow:
    0px 0px 4px 1px rgba(12, 205, 223, 0.4),
    0px 0px 16px 5px rgba(12, 205, 223, 0.5);
}

.scrollbar-effect {
  width: 100%;
  height: 80%;
}

.glow {
  background-color: #333;
  border-radius: 4px;
  background: linear-gradient(
    45deg,
    rgba(255, 255, 255, 0) 20%,
    rgba(255, 255, 255, 0.8) 50%,
    rgba(255, 255, 255, 0) 80%
  );
  animation: flow 3s linear infinite;
}

@keyframes flow {
  0% {
    transform: translateY(-100%);
  }
  100% {
    transform: translateY(100%);
  }
}

.list {
  width: 100%;
  padding-bottom: 20px;
  padding-left: 20px;
  padding-right: 20px;
  height: calc(100% - 48px);
  list-main-axis-gap: 10px;
  list-cross-axis-gap: 10px;
}

.picture-wrapper {
  border-radius: 10px;
  overflow: hidden;
  width: 100%;
}

.like-icon {
  position: absolute;
  display: grid;
  justify-items: center;
  align-items: center;
  top: 0px;
  right: 0px;
  width: 48px;
  height: 48px;
}

.heart-love {
  width: 16px;
  height: 16px;
}

.circle {
  position: absolute;
  top: calc(50% - 8px);
  left: calc(50% - 8px);
  height: 16px;
  width: 16px;
  border: 2px solid red;
  border-radius: 50%;
  transform: scale(0);
  opacity: 1;
  animation: ripple 1s 1 ease-out;
}

.circleAfter {
  animation-delay: 0.5s;
}

@keyframes ripple {
  0% {
    transform: scale(1);
    opacity: 1;
  }
  100% {
    transform: scale(2);
    opacity: 0;
  }
}

然后在你的入口文件中导入它:

import '../gallery.css';

这样可以确保你在跟随本教程时 UI 看起来很棒。

Lynx 中的样式方案

Lynx 支持多种样式特性,包括全局样式、CSS Modules、内联样式、Sass、CSS 变量等!请参阅 Rspeedy - 样式了解如何选择最适合你的样式配置。

你的第一个组件:图片卡片

现在,让我们从创建第一个图片卡片开始,它将是这个页面的主要部分。

很好,你现在可以看到图片卡片已经显示出来了。这里我们使用 <image> 元素来展示图片。你只需要给它一个宽度和高度(或者像这里展示的那样指定 aspectRatio 属性),它就会自动调整大小以适应指定的尺寸。这个组件可以通过 defineProps 接收一个 picture 属性,允许你更改它显示的图片。

图片的 src 属性

Lynx 的 <image> 元素可以接受本地相对路径作为 src 属性来渲染图片。本页面中的所有图片都来自本地,这些路径需要在使用前导入。但是,如果你的图片存储在网上,你可以轻松地将它们替换为网络图片地址。

Vue Lynx 入口点

与标准 Vue 一样,Vue Lynx 使用 createApp(Component).mount()。区别在于没有 DOM 元素可以挂载——mount() 不带参数调用,会自动附加到 Lynx 页面根节点。

添加交互:收藏图片卡片

我们可以在右上角添加一个白色小爱心,使其成为图片卡片的收藏按钮。这里我们实现了一个名为 LikeIcon 的小组件:

我们希望每张卡片知道自己是否已被收藏,所以添加了 isLiked,这是它的内部数据。它可以使用这个内部数据来保存你的更改。

LikeIcon.vue
<script setup lang="ts">
const isLiked = ref(false);
</script>

ref(false) 创建一个响应式变量。当 isLiked.value 改变时,Vue 的响应式系统会自动重新渲染组件。

然后我们在包裹的 <view> 上添加 @tap 事件,这样当用户点击爱心时,就会触发这个事件并改变 isLiked 的状态:

LikeIcon.vue
<script setup lang="ts">
function onTap() {
  isLiked.value = true;
}
</script>

<template>
  <view class="like-icon" @tap="onTap">
    ...
  </view>
</template>
什么是 "@tap"?

Vue Lynx 使用你在 Web 端 Vue 中熟悉的 @eventName 语法,但 Lynx 有自己的一组事件——用 @tap 代替 @click,用 @scroll 代替原生 scroll 等。在事件处理页面了解更多。

最后,我们使用 isLiked 来控制收藏效果。因为 isLiked 是响应式的,LikeIcon 会响应它的变化,变成红色爱心图标。用于渲染涟漪动画的 <view> 元素通过 v-if 进行条件渲染:

LikeIcon.vue
<template>
  <view class="like-icon" @tap="onTap">
    <view v-if="isLiked" class="circle" />
    <view v-if="isLiked" class="circle circleAfter" />
    <image :src="isLiked ? redHeart : whiteHeart" class="heart-love" />
  </view>
</template>

然后我们将 LikeIconImageCard 组合成一个 LikeImageCard 组件(参见上方查看器中的代码)。

为了给收藏提供更好的视觉交互效果,我们在 gallery.css 中添加了 CSS 动画。你也可以在动画章节了解更多关于动画的内容,并替换成你喜欢的样式!

使用 <list> 展示更多图片

要展示你所有的精美图片,你可能需要 <list> 的帮助。这样,你将得到一个可滚动的页面来展示大量相似的图片:

list 的特殊子元素

<list> 的每个子元素都需要是 <list-item>,并且你必须指定唯一且不重复的 :key:item-key 属性,否则可能无法正确渲染。

我们使用瀑布流布局作为子节点的布局选项。<list> 还支持其他布局类型,你可以参考 <list> 文档了解更多。

Info

你可以参考滚动文档来了解更多关于滚动和滚动元素的内容。

通过元素方法实现自动滚动

如果你想创建一个桌面照片墙,你需要为这个页面添加自动滚动功能。你的图片将缓慢地自动滚动,让你能轻松地看到更多图片:

GalleryAutoScroll/Gallery.vue
<script setup lang="ts">
import { onMounted, nextTick } from 'vue-lynx';

onMounted(() => {
  nextTick(() => {
    lynx
      .createSelectorQuery()
      .select('[custom-list-name="list-container"]')
      .invoke({
        method: 'autoScroll',
        params: { rate: '60', start: 'true' },
      })
      .exec();
  });
});
</script>

我们使用 onMounted 结合 nextTick 在列表渲染完成后调用 autoScroll 方法。我们使用 lynx.createSelectorQuery() 通过 custom-list-name 属性选择列表元素并调用原生方法。

什么是 "invoke"?

在 Lynx 中,所有原生元素都有一组可以调用的"方法"。与 Web 不同,这种调用是异步的,类似于消息传递。你需要使用 invoke 并提供方法名和参数来调用它们。

onMounted + nextTick

onMounted 在组件挂载时运行一次。我们在其中调用 nextTick 来等待主线程完成原生元素的创建。在 Vue Lynx 中,nextTick 被增强为等待跨线程操作刷新——而不仅仅是 Vue 的内部调度器——因此在回调触发时元素保证已经存在。

添加自定义滚动条

和大多数应用一样,我们可以在这个页面添加一个滚动条来指示还有多少图片待展示。但我们可以做得更多!例如,我们可以用自己喜欢的样式替换 <list> 的默认进度条:

NiceScrollbar 组件通过 defineExpose 暴露了一个 adjustScrollbar 方法,允许父组件调用子组件的方法。与添加收藏功能时使用的 @tap 事件类似,我们在 <list> 上添加了 @scroll 事件,它会在 <list> 元素滚动时被触发。为了让滚动条更快地响应滚动事件,我们将 scroll-event-throttle 设置为 0。

list-item 的 estimated-main-axis-size-px

你可能注意到了 <list-item> 上的这个属性。它在 <list> 中元素尚未渲染时估算元素在主轴上的大小。当我们添加滚动条时这非常有用,因为我们需要知道滚动条需要多长才能覆盖所有元素。

我们提供了一个实用方法来根据当前布局和图片尺寸估算大小:

utils.ts
export const calculateEstimatedSize = (
  pictureWidth: number,
  pictureHeight: number,
): number => {
  const galleryPadding = 20;
  const galleryMainAxisGap = 10;
  const gallerySpanCount = 2;
  const galleryWidth = SystemInfo.pixelWidth / SystemInfo.pixelRatio;
  const itemWidth = (galleryWidth - galleryPadding * 2 - galleryMainAxisGap)
    / gallerySpanCount;
  return (itemWidth / pictureWidth) * pictureHeight;
};

至此,我们已经有了一个完整的页面!但你可能注意到,我们添加的滚动条在滚动时仍有一些延迟,响应不够灵敏。这是因为我们的调整仍然发生在后台线程上,而不是响应触摸滚动的主线程上。

什么是后台线程和主线程?

Lynx 最大的特点是其双线程架构。你可以在 JavaScript 运行时中找到更详细的介绍。

更灵敏的滚动条

为了优化滚动条的性能,我们需要引入主线程脚本(MTS)在主线程上处理事件,将我们在上一步中对滚动条高度和位置的调整从后台线程迁移到主线程。

为了让你更清楚地看到对比,我们保留了两个滚动条:

MTS 滚动条使用 :main-thread-refMainThreadRef 绑定到元素上,允许主线程直接操作它,无需任何后台线程的往返通信。

现在你应该能看到,左侧由主线程脚本控制的滚动条(darkkhaki 色)比我们之前实现的右侧滚动条更流畅、更灵敏。如果你在其他 UI 中遇到需要立即更新的问题,可以尝试这种方法。

Vue 中 MTS 的关键概念:

  • useMainThreadRef(null) — 创建一个可以在主线程上解析的 ref
  • :main-thread-bindscroll="ctx" — 将 worklet 处理器绑定到滚动事件(在主线程上运行,零线程切换)
  • .toJSON() — 序列化 ref 以进行跨线程传输(包含内部 _wvid 标识符)

总结

我们移除了用于对比的多余滚动条,画廊现在完成了!让我们看看最终效果:

恭喜!你已经成功创建了一个商品画廊页面!在本教程中,你已经掌握了使用 Vue 3 在 Lynx 平台上编写交互式 UI 的基础知识。

React 与 Vue 快速对照

概念React LynxVue Lynx
创建应用root.render(<App />)createApp(App).mount()
状态const [x, setX] = useState(val)const x = ref(val)
更新状态setX(newVal)x.value = newVal
模板 refconst ref = useRef(null)const el = ref(null)
生命周期useEffect(() => ..., [])onMounted(() => ...)
类名className="foo"class="foo"
条件渲染{cond && <el />}<el v-if="cond" />
列表{arr.map(x => <el key={x} />)}<el v-for="x in arr" :key="x" />
点击事件bindtap={handler}@tap="handler"
滚动事件bindscroll={handler}@scroll="handler"
MTS 滚动main-thread:bindscroll={fn}:main-thread-bindscroll="ctx"
MTS refmain-thread:ref={ref}:main-thread-ref="ref"
主线程 ref 钩子useMainThreadRef(null)useMainThreadRef(null)
暴露给父组件forwardRef + useImperativeHandledefineExpose({ method })
Propsfunction Comp(props: { x: T })defineProps<{ x: T }>()

运行示例

# Install dependencies
pnpm install

# Build Vue runtime packages
cd packages/vue/runtime && pnpm build
cd packages/vue/main-thread && pnpm build

# Start the dev server
cd packages/vue/e2e-lynx && pnpm dev

然后在 LynxExplorer 中打开每个 bundle:

入口Bundle展示内容
ImageCardImageCard.lynx.bundle单个图片卡片
LikeCardLikeCard.lynx.bundle带点击收藏的卡片
GalleryListGalleryList.lynx.bundle瀑布流网格
GalleryAutoScrollGalleryAutoScroll.lynx.bundle自动滚动画廊
GalleryScrollbarGalleryScrollbar.lynx.bundle画廊 + 后台线程滚动条
GalleryScrollbarCompareGalleryScrollbarCompare.lynx.bundle后台 vs 主线程滚动条对比
GalleryCompleteGalleryComplete.lynx.bundle带 MTS 滚动条的最终画廊