列表渲染

在开发中,我们经常需要根据数组数据渲染一组相似的元素。Vitarx 提供了 For 组件来高效渲染动态列表。

基本用法

For 组件接收 each 属性作为数据源,children 作为渲染函数:

tsx
import { For, createApp } from 'vitarx'

function App() {
  const fruits = ['苹果', '香蕉', '橘子']

  return (
    <ul>
      <For each={fruits}>{(fruit) => <li>{fruit}</li>}</For>
    </ul>
  )
}

createApp(App).mount('#app')

渲染函数接收两个参数:

  • item:当前项的数据
  • index:当前项的索引,是一个 Ref<number>(响应式引用)
tsx
<For each={fruits}>
  {(fruit, index) => (
    <li>
      第 {index} 项: {fruit}
    </li>
  )}
</For>

INFO

index 是 Ref<number> 类型,在模板中会自动解包显示数值,在 JavaScript 代码中需要通过 index.value 访问。

key 属性

key 用于标识列表项的唯一性,帮助框架高效地更新列表。强烈建议始终提供 key:

字符串形式

当数据是对象数组时,传入唯一标识的属性名:

tsx
import { For, ref, createApp } from 'vitarx'

function App() {
  const users = ref([
    { id: 1, name: '张三' },
    { id: 2, name: '李四' },
    { id: 3, name: '王五' }
  ])

  return (
    <ul>
      <For each={users.value} key="id">
        {(user) => <li>{user.name}</li>}
      </For>
    </ul>
  )
}

createApp(App).mount('#app')

函数形式

当你需要更灵活地生成 key 时,可以传入函数:

tsx
<For each={users.value} key={(user) => user.id}>
  {(user) => <li>{user.name}</li>}
</For>

为什么需要 key

key 的作用是帮助框架识别哪些项发生了变化(添加、删除、移动)。没有 key 时,框架无法高效地复用已有 DOM,可能导致不必要的重建和状态丢失。

  • 有 key:框架通过 key 匹配新旧列表项,只更新变化的项,保持未变化项的 DOM 和状态
  • 没有 key:框架无法精确匹配,可能重建本可以复用的 DOM,性能较差

WARNING

key 的值必须在列表中唯一。重复的 key 会导致渲染异常。不要使用数组索引作为 key,因为当列表项增删时索引会变化,失去 key 的意义。

响应式列表

当列表数据是响应式的(如 ref 包装的数组),数据变化时列表会自动更新:

tsx
import { For, ref, createApp } from 'vitarx'

function App() {
  const items = ref([
    { id: 1, text: '学习 Vitarx' },
    { id: 2, text: '编写组件' },
    { id: 3, text: '构建应用' }
  ])

  let nextId = 4

  const addItem = () => {
    items.value = [...items.value, { id: nextId++, text: `新任务 #123;nextId - 1}` }]
  }

  const removeItem = (id: number) => {
    items.value = items.value.filter((item) => item.id !== id)
  }

  return (
    <div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
      <h2>待办列表</h2>
      <button onClick={addItem} style={{ marginBottom: '12px' }}>
        添加任务
      </button>
      <ul style={{ paddingLeft: '20px' }}>
        <For each={items.value} key="id">
          {(item) => (
            <li style={{ margin: '8px 0' }}>
              {item.text}
              <button onClick={() => removeItem(item.id)} style={{ marginLeft: '8px' }}>
                删除
              </button>
            </li>
          )}
        </For>
      </ul>
    </div>
  )
}

createApp(App).mount('#app')

列表数据更新

vitarx 的列表更新模型是「行内信号响应式 + 数据项快照」:

  • children 渲染函数只在行创建时执行一次,行内容通过 JSX 表达式读取 item 属性建立信号依赖;
  • key 决定行的身份——同 key 的行会被复用(保留 DOM 节点与行内状态,如输入焦点),children 不会重新执行。

因此更新列表数据有三条通道,按场景选择:

原地修改(推荐)

行数据变化时,直接修改响应式数组中该行对象的属性,行内信号会精确更新对应的 DOM:

tsx
const todos = ref<Todo[]>([...])

// 修改某行:原地改属性,仅该行的对应表达式更新
const toggleTodo = (id: number) => {
  const todo = todos.value.find((t) => t.id === id)
  if (todo) todo.done = !todo.done
}

syncList 同步全量数据

新增于 4.1.0 版本

从服务端拉取全量新数据时,用 syncList 把新数据按 key 回写进既有数组——不替换数组引用,同 key 行复用并元素级更新:

tsx
import { syncList } from 'vitarx'

async function refresh() {
  const fresh = await fetchTodos() // 全新对象的全量数据
  syncList(todos.value, fresh, 'id')
}

key 携带可变内容

当「内容变化即意味着新身份」(如版本化数据、缓存失效场景)时,把可变内容纳入 key,内容变化即精确重建该行:

tsx
<For each={articles} key={(a) => `#123;a.id}:#123;a.version}`}>
  {(article) => <ArticleCard data={article} />}
</For>

WARNING

tsx
// ❌ 直接替换数组,全部是新对象引用
const fresh = await fetchTodos()
todos.value = fresh

同 key 的行会被复用,但 children 不会重新执行——行内容不会自动更新,且开发模式下会输出聚合警告(提示使用上述三种姿势)。若确实需要消化全量新数据,请使用 syncList。

增删与重排则不受此限制——spread / filter / splice 等数组操作只要保持未变化项的对象引用稳定,For 就会按 key 复用既有行:

tsx
// ✅ 新数组,但旧项引用保持稳定:旧行全部复用,仅新行构建
todos.value = [...todos.value, { id: nextId++, text: '新任务' }]
todos.value = todos.value.filter((t) => t.id !== id)

完整示例

下面是一个功能完整的待办事项列表:

tsx
import { For, ref, createApp } from 'vitarx'

interface Todo {
  id: number
  text: string
  done: boolean
}

function App() {
  const todos = ref<Todo[]>([
    { id: 1, text: '学习 Vitarx', done: false },
    { id: 2, text: '编写组件', done: false },
    { id: 3, text: '构建应用', done: true }
  ])

  const newText = ref('')
  let nextId = 4

  const addTodo = () => {
    const text = newText.value.trim()
    if (!text) return
    todos.value = [...todos.value, { id: nextId++, text, done: false }]
    newText.value = ''
  }

  const toggleTodo = (id: number) => {
    // 原地修改:直接改该行对象的属性,行内信号精确更新(见「列表数据更新」)
    const todo = todos.value.find((t) => t.id === id)
    if (todo) todo.done = !todo.done
  }

  const removeTodo = (id: number) => {
    // filter 产生新数组,但未变化项的对象引用保持稳定,行按 key 复用
    todos.value = todos.value.filter((todo) => todo.id !== id)
  }

  return (
    <div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
      <h2>待办事项</h2>

      <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
        <input
          value={newText}
          onInput={(e) => {
            newText.value = (e.target as HTMLInputElement).value
          }}
          onKeyDown={(e) => {
            if (e.key === 'Enter') addTodo()
          }}
          placeholder="输入新任务"
          style={{ flex: 1, padding: '8px' }}
        />
        <button onClick={addTodo} style={{ padding: '8px 16px' }}>
          添加
        </button>
      </div>

      <ul style={{ listStyle: 'none', padding: 0 }}>
        <For each={todos.value} key="id">
          {(todo) => (
            <li
              style={{
                display: 'flex',
                alignItems: 'center',
                padding: '8px',
                marginBottom: '4px',
                background: '#f5f5f5',
                borderRadius: '4px'
              }}
            >
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => toggleTodo(todo.id)}
                style={{ marginRight: '8px' }}
              />
              <span
                style={{
                  flex: 1,
                  textDecoration: todo.done ? 'line-through' : 'none',
                  color: todo.done ? '#999' : '#333'
                }}
              >
                {todo.text}
              </span>
              <button
                onClick={() => removeTodo(todo.id)}
                style={{ color: '#e74c3c', cursor: 'pointer' }}
              >
                删除
              </button>
            </li>
          )}
        </For>
      </ul>

      <p style={{ color: '#999', fontSize: '14px', marginTop: '12px' }}>
        共 {todos.value.length} 项,已完成 {todos.value.filter((t) => t.done).length} 项
      </p>
    </div>
  )
}

createApp(App).mount('#app')

API 参考

tsx
interface ForProps<T> {
  /** 列表数据源 */
  each: readonly T[]
  /** 列表项渲染函数 */
  children: (item: T, index: Ref<number>) => RenderChild
  /** 唯一标识——属性名或函数 */
  key?: keyof T | ((item: T) => any)
}

// 使用方式
;<For each={list} key="id">
  {(item, index) => <div>{item.name}</div>}
</For>

下一步

  • 组件基础 — 深入学习组件的生命周期和高级用法