指南

列表

nsh/list 中的 List 在原生滚动视图上渲染长数据,但只创建屏幕附近的行。哪些行存在、放在哪里、多列布局、吸顶标题以及哪些条目可见,全部由原生计算;只有当原生端要一个单元格时,你的 JS 才去填充内容。一百万条的列表,开销和三十条的差不多。

guide-lists.tsx 在线编辑 在新标签页打开
import { effect, mount, signal, Text, View, type YNode } from "nsh";
import { List } from "nsh/list";

const tints = ["#fde2e4", "#e2ece9", "#dfe7fd", "#fff1e6", "#e8e8e4", "#cddafd"];
const muted = "#57606a";

type Post = { id: number; title: string };

/** Bumped when the selected tab is tapped again: its list scrolls to the top. */
const toTop = signal(0);

/** An endless feed: loads more near the end, pull to refresh, scroll to an item. */
function Feed() {
  let next = 0;
  const make = (n: number): Post[] => Array.from({ length: n }, () => ({ id: next, title: `Post ${++next}` }));
  const posts = signal(make(40));
  const refreshing = signal(false);
  const visible = signal("");
  let list!: YNode;
  effect(() => {
    if (toTop() > 0) list.scrollToOffset({ offset: 0 });
  });
  return (
    <List
      ref={list}
      each={posts()}
      keyExtractor={(p) => String(p.id)}
      estimatedItemSize={56}
      style={{ flex: 1 }}
      contentContainerStyle={{ padding: 12, gap: 8 }}
      onEndReached={() => posts.update((p) => [...p, ...make(20)])}
      onEndReachedThreshold={0.5}
      refreshing={refreshing()}
      onRefresh={() => {
        refreshing.set(true);
        setTimeout(() => {
          next = 0;
          posts.set(make(40));
          refreshing.set(false);
        }, 800);
      }}
      viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
      onViewableItemsChanged={({ viewableItems }) =>
        visible.set(viewableItems.length ? `${viewableItems[0].index}–${viewableItems[viewableItems.length - 1].index}` : "")
      }
      ListHeaderComponent={
        <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingBottom: 4 }}>
          <Text style={{ flex: 1, color: muted }}>
            {posts().length} posts, visible {visible()}
          </Text>
          <Text onPress={() => list.scrollToIndex({ index: 30, viewPosition: 0.5 })} style={{ color: "#0969da" }}>
            #30
          </Text>
          <Text onPress={() => list.scrollToOffset({ offset: 0 })} style={{ color: "#0969da" }}>
            Top
          </Text>
        </View>
      }
    >
      {(post) => (
        <View style={{ padding: 16, borderRadius: 10, backgroundColor: "#ffffff" }}>
          <Text style={{ fontSize: 16 }}>{post().title}</Text>
        </View>
      )}
    </List>
  );
}

/** Three columns of different heights. */
function Grid() {
  const items = Array.from({ length: 300 }, (_, i) => ({ id: i, height: 60 + (i % 4) * 18 }));
  return (
    <List each={items} numColumns={3} estimatedItemSize={80} style={{ flex: 1 }} contentContainerStyle={{ padding: 12, gap: 8 }}>
      {(item) => (
        <View style={{ height: item().height, borderRadius: 10, backgroundColor: tints[item().id % tints.length], alignItems: "center", justifyContent: "center" }}>
          <Text style={{ fontWeight: "600" }}>{item().id}</Text>
        </View>
      )}
    </List>
  );
}

/** Sticky letter headers: one list of header and name rows. */
function Contacts() {
  const names = ["Ada", "Alan", "Anita", "Barbara", "Bjarne", "Brian", "Claude", "Dennis", "Donald", "Edsger", "Frances", "Grace", "Guido", "Hedy", "Ken", "Linus", "Margaret", "Niklaus", "Radia", "Tim"];
  type Row = { header: string } | { name: string };
  const rows: Row[] = [];
  const headers: number[] = [];
  for (const letter of [...new Set(names.map((n) => n[0]))]) {
    headers.push(rows.length);
    rows.push({ header: letter });
    for (const name of names.filter((n) => n[0] === letter)) rows.push({ name });
  }
  return (
    <List each={rows} stickyHeaderIndices={headers} estimatedItemSize={44} style={{ flex: 1, backgroundColor: "#ffffff" }}>
      {(row) => (
        <If
          when={"header" in row()}
          else={
            <View style={{ paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#eaeef2" }}>
              <Text style={{ fontSize: 16 }}>{(row() as { name: string }).name}</Text>
            </View>
          }
        >
          <View style={{ backgroundColor: "#eaeef2", paddingHorizontal: 16, paddingVertical: 6 }}>
            <Text style={{ fontWeight: "700", color: muted }}>{(row() as { header: string }).header}</Text>
          </View>
        </If>
      )}
    </List>
  );
}

type Tab = "feed" | "grid" | "contacts";

function App() {
  const tab = signal<Tab>("feed");
  const button = (id: Tab, label: string) => (
    <Text
      onPress={() => (tab() === id ? toTop.update((n) => n + 1) : tab.set(id))}
      style={{
        flex: 1,
        textAlign: "center",
        paddingVertical: 6,
        borderRadius: 8,
        overflow: "hidden",
        fontWeight: "600",
        backgroundColor: tab() === id ? "#ffffff" : "transparent",
      }}
    >
      {label}
    </Text>
  );
  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", paddingTop: 24 }}>
      <View style={{ flexDirection: "row", marginHorizontal: 12, padding: 3, borderRadius: 10, backgroundColor: "#e1e4e8" }}>
        {button("feed", "Feed")}
        {button("grid", "Grid")}
        {button("contacts", "Sticky")}
      </View>
      {() => {
        switch (tab()) {
          case "feed":
            return <Feed />;
          case "grid":
            return <Grid />;
          case "contacts":
            return <Contacts />;
        }
      }}
    </View>
  );
}

mount(App);
import { mount, Text, View } from "nsh";
import { List } from "nsh/list";

const items = Array.from({ length: 10_000 }, (_, i) => ({ id: i, title: `Item ${i + 1}` }));

function App() {
  return (
    <List each={items} estimatedItemSize={52} style={{ flex: 1, backgroundColor: "#ffffff" }}>
      {(item) => (
        <View style={{ paddingHorizontal: 16, paddingVertical: 16, borderBottomWidth: 1, borderBottomColor: "#eaeef2" }}>
          <Text style={{ fontSize: 16 }}>{item().title}</Text>
        </View>
      )}
    </List>
  );
}

mount(App);

和 <Scroll> 一样,列表本身是视口,需要父元素给出尺寸(flex: 1 或一个高度)。

#复用是如何工作的

行函数不是每个条目调用一次,而是每个单元格调用一次,并且单元格会被复用:某一行滚出屏幕足够远后,它的单元格会被分配给另一个条目,item() 和 index() 随之改变。行中读取它们的地方都会原地更新,就像其他任何 signal 一样。

由此得出两条规则:

在用到的地方读取 item()。 在行函数开头复制出来的值,只属于这个单元格第一次显示的那个条目。

// 错误:单元格被复用后,`title` 仍是第一个条目的标题。
{(item) => {
  const title = item().title;
  return <Text>{title}</Text>;
}}

// 正确:文本跟随单元格当前的条目。
{(item) => <Text>{item().title}</Text>}

把每个条目的状态放在数据里,而不是行里。 在行内创建的 signal 属于单元格,会跟着它去到下一个条目。“这一行是否展开 / 选中 / 点赞” 应该存放在条目中,或存放在以 id 为键的映射里:

const liked = signal<Set<number>>(new Set());
const toggle = (id: number) =>
  liked.update((s) => {
    const next = new Set(s);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });

<List each={posts()} estimatedItemSize={64} style={{ flex: 1 }}>
  {(post) => (
    <View style={{ flexDirection: "row", padding: 16 }}>
      <Text style={{ flex: 1 }}>{post().title}</Text>
      <Text onPress={() => toggle(post().id)}>{liked().has(post().id) ? "♥" : "♡"}</Text>
    </View>
  )}
</List>

各行可以高度不同。在测量之前,列表假定行高为 estimatedItemSize(默认 50 点);传入接近典型行高的值,滚动条和跳转到远处条目从一开始就会比较准确。

#修改数据

传入新数组即可改变条目:posts.set([...posts(), ...more])。列表会按条目标识(===)比较新旧数组,保留相同的开头和结尾,中间部分视为删除和插入。测量结果跟随各自的条目;在屏幕上可见的行之上插入条目时,可见行保持原位,不会跳动。

  • 用不可变的方式更新:新数组,未改变的条目对象原样复用。原地修改数组(push)不会被察觉。
  • 改动过的条目应是一个新对象({ ...post, title }),这样它的行才会显示变化。
  • 在前面插入(新消息、刷新后的信息流)会保留用户的阅读位置。

keyExtractor 为可见性回调中的条目命名(见下文);默认依次取 item.key、item.id、索引。它不影响改动的匹配方式,匹配总是按标识进行。

#属性

List 接受 <Scroll> 中对列表有意义的所有滚动属性(pagingEnabled、snapToInterval、snapToOffsets、snapToAlignment、decelerationRate、disableIntervalMomentum、bounces、scrollEnabled、contentInset、contentOffset、keyboardShouldPersistTaps、keyboardDismissMode、scrollEventThrottle、onScroll、onScrollBeginDrag、onScrollEndDrag、onMomentumScrollBegin、onMomentumScrollEnd、onScrollEnd、scrollValue),说明见滚动。列表不能缩放。它自己的属性:

属性类型默认值说明
eachreadonly T[]条目。
children(item: () => T, index: () => number) => JSX.Element渲染一个单元格;每个单元格调用一次,见复用。
estimatedItemSizenumber50尚未测量的行的尺寸,单位为点。
horizontalbooleanfalse横向滚动。
numColumnsnumber1每行的条目数(纵向列表)。
invertedbooleanfalse第 0 项在底部(聊天);初始滚动到末尾,并保持与末尾的距离。
stickyHeaderIndicesnumber[]滚过时吸附在顶部的条目(单列且非倒置)。
contentContainerStyle{ padding*, gap, rowGap, columnGap }内容的内边距和间距,只接受数字。
ListHeaderComponent组件或元素在条目之前。
ListFooterComponent组件或元素在条目之后。
ListEmptyComponent组件或元素each 为空时代替条目显示。
ItemSeparatorComponent组件或元素条目之间(单列)。
initialScrollIndexnumber第一帧之前滚动到该条目。
onEndReached() => void接近末尾(见下文)。
onEndReachedThresholdnumber1距离末尾多少个视口时触发 onEndReached。
onViewableItemsChanged(info) => void有条目变为可见或不再可见。
viewabilityConfigViewabilityConfig什么情况下条目算作可见。
keyExtractor(item, index) => string可见性回调中的键。
refreshing / onRefreshboolean / () => void下拉刷新。
showsIndicatorbooleantrue显示滚动条。
refYNodescrollToIndex、scrollToOffset、scrollToEnd、scrollTo。

头部、尾部和空状态插槽接受组件(ListHeaderComponent={Header})或元素(ListHeaderComponent={<Header />})。它们是独立的单元格,从不被复用,并且和其他 JSX 一样是响应式的。

#多列与网格

numColumns 把条目排成每行若干列;contentContainerStyle 为内容四周添加内边距,并设置行间距(rowGap)、列间距(columnGap)或两者(gap)。网格中各行的高度可以不同:每行与其中最高的条目一样高。

<List
  each={photos}
  numColumns={3}
  estimatedItemSize={120}
  style={{ flex: 1 }}
  contentContainerStyle={{ padding: 12, gap: 8 }}
>
  {(photo) => <Image src={photo().thumb} style={{ aspectRatio: 1, borderRadius: 8 }} />}
</List>

#吸顶标题

stickyHeaderIndices 列出的条目在内容滚过时吸附在视口顶部,直到下一个吸顶条目把它推出去。分组列表就是一个由标题条目和普通行组成的列表,标题的索引设为吸顶。吸附在每一帧滚动时由原生完成。

type Row = { header: string } | { name: string };

const rows: Row[] = [];
const headers: number[] = [];
for (const [letter, names] of groups) {
  headers.push(rows.length);
  rows.push({ header: letter });
  for (const name of names) rows.push({ name });
}

<List each={rows} stickyHeaderIndices={headers} estimatedItemSize={44} style={{ flex: 1 }}>
  {(row) => (
    <If
      when={"header" in row()}
      else={<Text style={{ padding: 12 }}>{(row() as { name: string }).name}</Text>}
    >
      <Text style={{ padding: 6, backgroundColor: "#eaeef2", fontWeight: "700" }}>{(row() as { header: string }).header}</Text>
    </If>
  )}
</List>

同一个单元格可能这会儿显示标题、过一会儿显示名字,所以行用 <If> 决定渲染什么,它会在条目改变时切换。吸顶标题要求单列且列表不倒置。

#聊天:倒置列表

inverted 让列表从末尾开始排版:第 0 项在底部,列表初始滚动到末尾,并在条目变化时保持与末尾的距离。把最新的消息放在数组最前面;添加消息时插到前面,用 onEndReached 加载更早的消息时追加到后面(倒置列表的”末尾”是顶部)。

import { Input, mount, signal, Text, View } from "nsh";
import { List } from "nsh/list";
import { KeyboardAvoidingView } from "nsh/ui/mobile";

type Message = { id: number; text: string; mine: boolean };

function Chat() {
  let next = 0;
  const message = (text: string, mine: boolean): Message => ({ id: next++, text, mine });
  const messages = signal(Array.from({ length: 30 }, (_, i) => message(`Message ${30 - i}`, i % 3 === 0)));
  const draft = signal("");
  const send = () => {
    if (!draft().trim()) return;
    messages.update((m) => [message(draft(), true), ...m]); // 最新的在最前
    draft.set("");
  };
  const loadOlder = () => messages.update((m) => [...m, ...Array.from({ length: 20 }, (_, i) => message(`Older ${i}`, false))]);
  return (
    <KeyboardAvoidingView style={{ flex: 1 }}>
      <List
        each={messages()}
        inverted
        estimatedItemSize={44}
        style={{ flex: 1 }}
        contentContainerStyle={{ padding: 12, gap: 6 }}
        keyboardDismissMode="interactive"
        onEndReached={loadOlder}
        onEndReachedThreshold={0.5}
      >
        {(m) => (
          <View
            style={{
              alignSelf: m().mine ? "flex-end" : "flex-start",
              maxWidth: "75%",
              paddingHorizontal: 12,
              paddingVertical: 8,
              borderRadius: 14,
              backgroundColor: m().mine ? "#0969da" : "#eaeef2",
            }}
          >
            <Text style={{ color: m().mine ? "#ffffff" : "#1f2328" }}>{m().text}</Text>
          </View>
        )}
      </List>
      <View style={{ flexDirection: "row", gap: 8, padding: 8 }}>
        <Input
          value={draft()}
          onChangeText={(t) => draft.set(t)}
          onSubmitEditing={send}
          blurOnSubmit={false}
          returnKeyType="send"
          placeholder="Message"
          style={{ flex: 1, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 18, paddingHorizontal: 12, paddingVertical: 8 }}
        />
      </View>
    </KeyboardAvoidingView>
  );
}

mount(Chat);

在倒置列表上调用 scrollToEnd() 会滚动到它的开头,也就是最新的消息。

#横向列表

horizontal 让列表横向排列;单元格按列表的高度排版。配合吸附可以做出任意长度的轮播:

<List
  each={cards}
  horizontal
  estimatedItemSize={232}
  snapToInterval={232}
  decelerationRate="fast"
  showsIndicator={false}
  style={{ height: 140 }}
  contentContainerStyle={{ paddingHorizontal: 16, gap: 12 }}
>
  {(card) => <View style={{ width: 220, height: 140, borderRadius: 14, backgroundColor: card().color }} />}
</List>

在从右到左布局中,横向列表从右侧开始向左排列。

#加载更多:onEndReached

当视口距离末尾不到 onEndReachedThreshold 个视口时(默认 1:提前一屏),触发 onEndReached。追加下一页数据;新的末尾临近时会再次触发。

const posts = signal<Post[]>([]);
let loading = false;
let page = 0;

const loadMore = async () => {
  if (loading) return;
  loading = true;
  try {
    const next: Post[] = await (await fetch(`https://api.example.com/posts?page=${++page}`)).json();
    posts.set([...posts(), ...next]);
  } finally {
    loading = false;
  }
};
loadMore();

<List
  each={posts()}
  estimatedItemSize={72}
  style={{ flex: 1 }}
  onEndReached={loadMore}
  onEndReachedThreshold={0.5}
  ListFooterComponent={<Text style={{ padding: 16, textAlign: "center", color: "#8c959f" }}>Loading…</Text>}
>
  {(post) => <PostRow post={post()} />}
</List>

像上面那样防止重复加载:在一页数据返回之前,末尾可能会被再次报告。

#可见性

onViewableItemsChanged 报告哪些条目在屏幕上,可用于统计曝光、自动播放可见的视频或把消息标记为已读。它收到 { viewableItems, changed },两者都是由 { item, key, index, isViewable } 组成的数组。可见性由原生计算,JS 只在变化时收到通知。

viewabilityConfig说明
itemVisiblePercentThreshold条目必须可见的百分比。
viewAreaCoveragePercentThreshold条目必须覆盖视口的百分比。
minimumViewTime条目保持可见多少毫秒后才被报告。

不设阈值时,只要有一个像素可见就算可见。

<List
  each={videos}
  estimatedItemSize={300}
  style={{ flex: 1 }}
  keyExtractor={(v) => v.slug}
  viewabilityConfig={{ itemVisiblePercentThreshold: 60, minimumViewTime: 250 }}
  onViewableItemsChanged={({ viewableItems }) => playing.set(viewableItems[0]?.key ?? null)}
>
  {(v) => <VideoCard video={v()} />}
</List>

#从代码滚动

给列表设置 ref:

方法说明
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })把第 index 项滚动到可见。viewPosition 决定位置:0 在视口开头(默认),0.5 在中间,1 在末尾;viewOffset 再额外偏移。
scrollToOffset({ offset, animated? })沿列表主轴滚动到内容偏移处。
scrollToEnd({ animated? })滚动到内容末尾(倒置列表则是开头)。
scrollTo({ x?, y?, animated? })滚动到某个偏移,与 <Scroll> 相同。
import { type YNode } from "nsh";
import { List } from "nsh/list";

let list!: YNode;

<List ref={list} each={items} estimatedItemSize={48} style={{ flex: 1 }}>…</List>

list.scrollToIndex({ index: 120, viewPosition: 0.5 });

scrollToIndex 由原生根据当时已知的尺寸计算:已测量的行用实测值,其余用 estimatedItemSize。在行高差异很大的列表中跳到很远的位置可能会略有偏差;估计值越准,偏差越小。要让列表一打开就停在某个条目,请用 initialScrollIndex,而不是在第一帧之后再滚动。

#nsh/ui/mobile 中的 ListView

nsh/ui/mobile 中的 ListView 是基于同一套机制的现成列表:你传入 each 并渲染行,它负责内边距、分隔线以及 inset 分组外观。需要与移动端 UI 套件其他部分外观一致的设置类列表和普通列表时用它;需要完全掌控外观时用 List。见 ListView。

import { Text } from "nsh";
import { ListView } from "nsh/ui/mobile";

<ListView variant="inset" dividers each={settings} keyExtractor={(s) => s.id}>
  {(s) => <Text>{s().label}</Text>}
</ListView>

#性能提示

  • 每个单元格单独排版,滚动时只重新排版有变化的单元格。行的层级保持浅一些,避免在行里读取每帧都会变化的 signal。
  • 原生端分批请求单元格,每帧可以进行多轮,合并成一次更新;快速滑动时一帧内绑定许多单元格,也不会每个单元格调用一次 JS。
  • 用 nsh trace --scroll 测量行在每一帧的开销。

#相关内容