教程:产品详情
本教程将通过实现一个轮播组件,带你学习如何编写高性能的交互代码。你将学到:
我们要构建什么?
让我们先看看这个应用的最终效果!要体验实际效果,请先下载安装 Lynx Explorer App,然后用它扫描下面的二维码。
教程设置
请查看我们详细的安装文档,该文档将指导你创建一个新的 Lynx 项目。
你可能会注意到该项目使用的是 TypeScript。虽然 Lynx 和 ReactLynx 都支持 TypeScript 和普通 JavaScript,但我们推荐使用 TypeScript,以提供更好的开发体验,包括静态类型检查和更好的编辑器智能感知。
这个教程中将展示很多漂亮图片,我们为你准备了一些基准图片,你可以在这里下载资源包,并在搭建中使用它们。
直接操作节点
这里是一个产品详情页的实例,它包含一个轮播图和一些商品信息。其中,轮播图接受一个图片列表,并将其平铺展示。现在它没有办法滚动,让我们来让它动起来。
为此,我们需要完成下面两件事情:
- 监听手指触摸
- 更新滚动位置
监听手指触摸
让我们首先通过监听手指触摸,计算出当前的滚动进度。
在触摸开始时,我们记录手指触摸的起始坐标,这样在手指移动时,我们可以计算出手指移动的距离,用 delta 表示。
index.tsx
function Swiper() {
const touchStartXRef = useRef<number>(0);
function handleTouchStart(e: TouchEvent) {
touchStartXRef.current = e.touches[0].clientX;
}
function handleTouchMove(e: TouchEvent) {
const delta = e.touches[0].clientX - touchStartXRef.current;
}
return (
<view
class="swiper-container"
bindtouchstart={handleTouchStart}
bindtouchmove={handleTouchMove}
>
{/* ... */}
</view>
);
}
接下来,我们使用 currentOffsetRef 来记录轮播组件的偏移量,与 delta 相加,得到最终的偏移量。
index.tsx
function Swiper() {
const currentOffsetRef = useRef<number>(0);
const touchStartXRef = useRef<number>(0);
const touchStartCurrentOffsetRef = useRef<number>(0);
function handleTouchStart(e: TouchEvent) {
touchStartXRef.current = e.touches[0].clientX;
touchStartCurrentOffsetRef.current = currentOffsetRef.current;
}
function handleTouchMove(e: TouchEvent) {
const delta = e.touches[0].clientX - touchStartXRef.current;
const offset = touchStartCurrentOffsetRef.current + delta;
}
}
更新滚动位置
获取到偏移量后,我们就可以更新滚动位置了。增加 updateSwiperOffset 函数,在手指移动时调用它。
index.tsx
function Swiper() {
function updateSwiperOffset(offset: number) {
// 更新滚动位置
}
function handleTouchMove(e: TouchEvent) {
const delta = e.touches[0].clientX - touchStartXRef.current;
const offset = touchStartCurrentOffsetRef.current + delta;
updateSwiperOffset(offset);
}
}
接下来,我们使用操作节点获取 swiper-container 的节点,并使用 setNativeProps 更新 transform 属性,从而更新滚动位置。
index.tsx
function Swiper() {
const containerRef = useRef<NodesRef>(null);
function updateSwiperOffset(offset: number) {
containerRef.current
?.setNativeProps({
style: {
transform: `translateX(${offset}px)`,
},
})
.exec();
}
return <view ref={containerRef}></view>;
}
现在,<Swiper> 组件可以跟随手指滚动了!
为什么不使用 state 更新进度?
你可能会想到使用 state 来更新进度,像这样:
Swiper.tsx
function Swiper() {
const [offset, setOffset] = useState(0);
return (
<view style={{ transform: `translateX(${offset}px)` }}>{/* ... */}</view>
);
}
但是,在需要频繁更新的场景下,这种方式会导致组件不断重渲染,影响性能。更好的方式是直接操作节点,就像示例中那样。
你可以参考直接操作节点了解更多。
用 hooks 简化代码
<Swiper> 组件的代码已经比较复杂了,我们可以用 hooks 将上面的逻辑封装为两部分,简化组件代码,提高代码的可维护性:
- 将监听手指触摸中的代码封装为
useOffset,所有滚动相关的逻辑都集中在这个 hook 中
- 将更新滚动位置中的代码封装为
useUpdateSwiperStyle,所有更新 <Swiper> 组件样式的逻辑都集中在这个 hook 中
Swiper.tsx
function Swiper() {
const { updateSwiperStyle, swiperContainerRef } = useUpdateSwiperStyle();
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useOffset({
onOffsetUpdate: updateSwiperStyle,
});
return (
<view
class="swiper-container"
ref={swiperContainerRef}
bindtouchstart={handleTouchStart}
bindtouchmove={handleTouchMove}
bindtouchend={handleTouchEnd}
>
{/* ... */}
</view>
);
}
最终,代码变得更简洁了。
使用主线程脚本降低延迟
你可能已经注意到,当手指滚动时,有时会有不跟手的感觉。这是因为触摸事件发生在主线程,事件监听代码运行在后台线程,导致触摸事件的响应不够及时。这个现象在低端机上尤为明显。
我们可以使用主线程脚本来优化这个问题。可以看到,改造成主线程脚本后,滚动变得更加跟手了!
为了实现这个效果,我们需要将频繁触发的代码迁移到主线程脚本中,这包括:
- 事件监听代码
- 更新节点位置的代码
接下来我们分别对 useOffset 和 useUpdateSwiperStyle 进行改造。
useOffset
为 handleTouchStart 和 handleTouchMove 增加 main thread 标识,将它们改造成主线程函数。
useOffset.ts
function useOffset() {
const touchStartXRef = useMainThreadRef<number>(0);
function handleTouchStart(e: TouchEvent) {
'main thread'
...
}
function handleTouchMove(e: TouchEvent) {
'main thread'
...
}
}
将 bindtouchstart 和 bindtouchmove 改造成 main-thread:bindtouchstart 和 main-thread:bindtouchmove,这样就可以在主线程脚本中监听事件。
index.tsx
<view
main-thread:bindtouchstart={handleTouchStart}
main-thread:bindtouchmove={handleTouchMove}
>
{/* ... */}
</view>
useUpdateSwiperStyle
将 useRef 改造为 useMainThreadRef。
useUpdateSwiperStyle.ts
function useUpdateSwiperStyle() {
const swiperContainerRef = useMainThreadRef<MainThread.Element>(null);
function updateSwiperStyle(offset: number) {
'main thread'
...
}
return {
swiperContainerRef,
}
}
将 swiperContainerRef 通过 main-thread:ref 属性传递给 <view>,这样就可以在主线程获取这个节点。
index.tsx
<view main-thread:ref={swiperContainerRef}>{/* ... */}</view>
主线程获取的节点提供了很多能力,可以参考 MainThread.Element。这里我们调用 setStyleProperties 方法修改 transform 属性,更新 <Swiper> 组件的位置。
useUpdateSwiperStyle.ts
function useUpdateSwiperStyle() {
const swiperContainerRef = useMainThreadRef<MainThread.Element>(null);
function updateSwiperStyle(offset: number) {
'main thread';
swiperContainerRef.current?.setStyleProperties({
transform: `translateX(${offset}px)`,
});
}
}
这样,我们完成了主线程脚本改造。现在高频触发的函数在主线程中运行,更加跟手了。
节制地使用主线程脚本
在遇到频繁触发的事件导致的响应延迟问题时,再使用主线程脚本!
主线程函数与后台线程函数互相调用
这里是一个进度指示器的示例,当手指滚动时,进度指示器会显示当前处于哪一页。
现在它只有样式,缺少更新进度的逻辑。我们通过这个例子演示如何让主线程与后台线程相互通信:
主线程调用后台线程
进度指示器的核心是 <Indicator> 组件,它接收一个 current prop,表示当前处于哪一页。
Swiper.tsx
function Swiper() {
const [current, setCurrent] = useState(0);
return (
<view>
{/* ... */}
<Indicator current={current} />
</view>
);
}
现在,我们只需要在手指滚动时,更新 current 即可。
我们为 useOffset 增加 onIndexChange 回调,当手指滚动时,更新 current。
useOffset.ts
function useOffset({
itemWidth,
onIndexUpdate,
}) {
const currentIndexRef = useMainThreadRef<number>(0);
function updateOffset(offset: number) {
...
const index = Math.round(offset / itemWidth);
if (currentIndexRef.current !== index) {
currentIndexRef.current = index;
onIndexUpdate(index);
}
}
}
并将 setCurrent 作为 onIndexUpdate 的回调传递给 useOffset。
Swiper.tsx
const [current, setCurrent] = useState(0);
const { handleTouchMove } = useOffset({
onIndexUpdate: setCurrent,
});
这样,当手指滚动时超过一页时,useOffset 会调用 onIndexUpdate 更新 current,从而更新进度指示器。
但是等等,为什么会有报错?!

主线程函数与后台线程函数需要特殊 API 才能互相调用
主线程脚本与后台线程脚本在两个独立运行时中运行,一个运行时内的函数不能直接调用另一个运行时中的函数,他们之间需要“特殊的桥梁”才能互相通信:
onIndexUpdate 是一个后台线程函数,在主线程函数中调用时,需要使用 runOnBackground
useOffset.ts
function useOffset({
itemWidth,
onIndexUpdate,
}) {
const currentIndexRef = useMainThreadRef<number>(0);
function updateOffset(offset: number) {
...
const index = Math.round(offset / itemWidth);
if (currentIndexRef.current !== index) {
currentIndexRef.current = index;
runOnBackground(onIndexUpdate)(index);
}
}
}
现在,随着手指滚动,进度指示器会自动更新了!
后台线程调用主线程
一个好用的进度指示器还需要支持点击跳转到对应页面,下面我们为 <Indicator> 组件增加点击跳转的功能。
在 useOffset 中增加 updateIndex 方法,通过 runOnMainThread 调用 updateOffset 更新组件位置。
useOffset.ts
function useOffset({ itemWidth, onIndexUpdate }) {
function updateIndex(index: number) {
const offset = index * itemWidth;
runOnMainThread(updateOffset)(offset);
}
return {
updateIndex,
};
}
完整的代码如下
非常好,现在进度指示器已经支持点击跳转了。
主线程脚本与后台线程脚本值的传递
在下面的例子中,我们为 <Swiper> 组件增加了吸附动画,现在它的动画效果不够好,需要传入一些参数定制。
我们通过这个例子演示主线程与后台线程脚本中值的传递。
主线程脚本使用后台线程脚本的值
首先,我们为 <Swiper> 组件增加 duration 属性,来控制吸附动画的持续时间。
index.tsx
<Swiper duration={300} />
让我们看看内部是如何实现的。触摸结束时,useOffset 会调用 animate 函数,通过动画效果更新组件位置。animate 函数是一个主线程函数,接受初始值和目标值,并以动画曲线按照 duration 时间更新组件位置。
useOffset.ts
function useOffset({
duration,
}) {
const currentOffsetRef = useMainThreadRef<number>(0);
function updateOffset(offset: number) {
'main thread'
// Update Component Offset
}
...
function handleTouchEnd() {
'main thread'
...
animate({
from: currentOffsetRef.current,
to: calcNearestPage(currentOffsetRef.current),
onUpdate: updateOffset,
duration,
})
}
}
这里,animate 和 handleTouchEnd 都是主线程函数,他们可以访问后台线程的值 duration。
主线程函数可以使用后台线程的值
主线程脚本和后台线程脚本在两个独立的运行时中运行,他们之间是互相隔离的。
但是,为了简化主线程脚本的开发,Lynx 会自动将主线程函数依赖的后台线程的值传递给主线程函数,不过这个过程存在一些限制:
- 依赖的后台线程值必须可序列化,因此,函数,
Promise 等不可序列化的值不能被传递。
- 值的传递只会在组件
render 时发生,如果后台线程的值在 render 后发生变化,主线程函数不会感知到这个更新。
后台线程传递主线程中的值
接下来,我们为 <Swiper> 组件增加 main-thread:easing 属性,让用户可以自定义动画曲线。
index.tsx
function easeInOut(x: number) {
'main thread';
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
<Swiper main-thread:easing={easeInOut} />;
在组件内部,主线程函数 easeInOut 被传递给后台线程的 hooks useOffset
Swiper.tsx
function Swiper({
'main-thread:easing': MTEasing,
}) {
...
const { handleTouchStart, ... } = useOffset({
MTEasing,
});
}
并在 useOffset 中被传递给主线程函数 animate 使用。
useOffset.ts
function useOffset({
duration,
MTEasing,
}) {
...
function handleTouchEnd(e: MainThread.TouchEvent) {
"main thread";
// ...
animate({
duration,
easing: MTEasing,
});
}
}
主线程中的值可以被后台线程传递,但是不能被后台线程使用
主线程中的值,比如 MainThreadRef,主线程函数,不能被后台线程直接使用。
但是它们可以被后台线程传递,比如作为 props 传入组件,或是通过函数入参传递给其他 hooks 或者函数,并最终在主线程中使用。
为需要传入主线程函数的 props 增加 main-thread: 前缀
你可能已经注意到,将主线程函数或是 MainThreadRef 作为 attribute 传入时,都需要加上 main-thread: 前缀,比如 main-thread:ref 和 main-thread:bindtouchstart。
按照约定,当一个 prop 预期接受主线程函数时,这个 props 需要有 main-thread: 前缀,比如 main-thread:easing。我们推荐对自定义组件也遵循这个约定。这样可以让组件用户了解这个属性需要传入主线程函数。
不过,因为 JavaScript 中包含冒号 : 的变量名是非法的,组件内部使用这个 prop 时需要重命名。
Swiper.tsx
function Swiper({ 'main-thread:easing': MTEasing }) {}
最终,我们拥有了一个可以自定义动画曲线的吸附动画。
总结
在本教程中,我们从一个简单的 <Swiper> 组件开始,逐步优化它的性能,并最终实现了一个可以自定义动画曲线的吸附动画。
我们学习了以下内容:
- 使用直接操作节点的方式优化性能
- 利用主线程脚本进一步提升交互体验
- 实现主线程与后台线程的通信
- 学习了主线程脚本与后台线程脚本中值的传递