summaryrefslogtreecommitdiff
path: root/source/_posts/svelte-lazyquery.md
blob: 94c337422596acd4884ce19022f650cb8852a23b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
---
title: 使用 Runed intersection observer 与 tanstack query 实现通用的惰性加载组件
date: 2025-07-31 18:49:45
tags: [Technique]
---

在前端,一个常见的性能瓶颈是初始加载时请求了用户当前 viewpoint 之外的非必要数据。惰性加载(Lazy Loading)是一种关键策略,它将数据获取推迟到组件进入 viewpoint 时才执行。本文将阐述如何结合使用 Svelte 5、`@tanstack/svelte-query` 和 `runed.dev` 的 `useIntersectionObserver`,构建一个通用的、可复用的惰性加载组件。

**核心依赖与环境**

- **Svelte 5:** 本实现依赖于 Svelte 5 的符文(Runes)特性,它提供了更精细、更直观的状态管理能力。
    
- **@tanstack/svelte-query:** TanStack Query 的 Svelte 适配版,用户获取数据。
    
- **runed.dev:** 一个提供多种 Svelte 5 实用工具的库,本文主要使用其 `useIntersectionObserver`。

#### **设计哲学:分离关注点**

该方案的核心思想是将“何时加载”与“如何加载”这两个关注点进行解耦。

1. **何时加载 (When to Load):** 组件的可见性决定了数据加载的时机。我们利用 `Intersection Observer API` 来精确、高效地监听一个元素是否进入 viewpoint。`runed.dev` 库为此提供了名为 `useIntersectionObserver` 的便捷封装。
    
2. **如何加载 (How to Load):** 数据获取、缓存、同步和状态管理的复杂性由 `@tanstack/svelte-query` (TanStack Query) 处理。它提供了一套强大的工具集来管理异步数据。
    

通过将这两者结合,可以创建一个名为 `LazyQuery` 的抽象组件。该组件内部处理可见性检测,并根据检测结果动态控制 TanStack Query 的执行,而将具体的查询逻辑(`queryFn`)和键(`queryKey`)完全交由使用者定义。

#### `LazyQuery` 组件的实现

> 目标:封装惰性加载逻辑,并向外暴露一个标准的 TanStack Query 接口。

一、对组件的接口类型做如下定义:

```typescript
import type { CreateQueryOptions, QueryKey } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte';

type Props<
  TQueryFnData = unknown,
  TError = Error,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
> = {
  /// 一个标准的 TanStack Query 配置对象
  queryOptions: CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey>;

  /// Svelte 5 的 `Snippet` 类型,它允许父组件向子组件传递一段可执行的 UI 模板。
  /// 这个 `Snippet` 会接收到 `createQuery` 返回的完整查询对象 `query`,
  /// 从而可以访问 `data`, `isLoading`, `error` 等所有状态。
  children: Snippet<{
    query: ReturnType<typeof createQuery<TQueryFnData, TError, TData, TQueryKey>>;
  }>;
};
```

二、组件逻辑:

```html
<script lang="ts">
  import { createQuery, type CreateQueryOptions, type QueryKey } from '@tanstack/svelte-query';
  import { useIntersectionObserver } from 'runed';
  import type { Snippet } from 'svelte';

  // [上述的类型定义]

  let { queryOptions, children }: Props = $props();

  let el: Element;
  const { isIntersecting } = useIntersectionObserver(
    () => el,
    {
      rootMargin: '200px',
    }
  );

  const query = createQuery({
    ...queryOptions,
    get enabled() {
      return ($state.is(isIntersecting) && (queryOptions.enabled ?? true));
    }
  });
</script>

<div bind:this={el}>
  {#snippet children({ query })}
  {/snippet}
</div>
```

- 我们创建一个 `div` 元素作为哨兵(sentinel),并用 `bind:this={el}` 将其 DOM 引用绑定到变量 `el`。
- `useIntersectionObserver` 接收一个返回目标元素的函数 `() => el`。
	- 它返回一个响应式的状态对象,其中 `isIntersecting` 是一个布尔值的符文(rune),当 `div` 元素进入 viewpoint 时为 `true`,否则为 `false`。
- `rootMargin: '200px'` 是一个优化选项,它会在元素距离 viewpoint 还有 200px 时就触发加载,从而提升用户体验。

#### 使用

使用 `LazyQuery` 组件非常直观。开发者只需关注数据获取的业务逻辑,而无需关心惰性加载的实现细节,假设有一个获取图表数据的场景:

```html
<script lang="ts">
  import { QueryClientProvider, QueryClient } from '@tanstack/svelte-query';
  import LazyQuery from './LazyQuery.svelte';
  import CopdGoldGradingChart from './CopdGoldGradingChart.svelte';

  const queryClient = new QueryClient();

  // Define the query configuration object, just like with a standard `createQuery`.
  const queryOptions = {
    queryKey: ['goldGradingData'],
    queryFn: async () => {
      // Simulate a network request
      await new Promise(resolve => setTimeout(resolve, 1500));
      // In a real application, this would be an API call
      // const response = await fetch('/api/gold-grading');
      // return await response.json();
      return { data: { totalPatients: 1234, chartPoints: [/* ... */] } };
    }
  };
</script>

<QueryClientProvider client={queryClient}>
  <div style="height: 200vh;">
    <p>Scroll down to see the chart...</p>
  </div>

  <LazyQuery {queryOptions}>
    {#snippet children({ query })}
      {#if query.isLoading}
        <p>Loading chart data...</p>
      {:else if query.error}
        <p>Error: {query.error.message}</p>
      {:else if query.data}
        <CopdGoldGradingChart
          isLoading={query.isFetching}
          error={query.error}
          refetch={() => query.refetch()}
          data={query.data.data}
          totalPatients={query.data.data.totalPatients}
        />
      {/if}
    {/snippet}
  </LazyQuery>
</QueryClientProvider>
```

- 页面初始加载时,`LazyQuery` 组件被渲染,但由于其 `div` 在 viewpoint 之外,`isIntersecting` 为 `false`。
- `createQuery` 被调用,但因为 `enabled` 条件为 `false`,查询处于禁用状态,不会发起任何网络请求。
- `children` 片段被渲染,此时 `query.isLoading` 为 `true`(这是 TanStack Query 禁用查询时的初始状态),显示 "Loading chart data..."。
- 当用户向下滚动,`div` 元素进入 viewpoint(或进入 `200px` 的预加载区域)。
- `useIntersectionObserver` 将 `isIntersecting` 的值更新为 `true`。
- 这个变化被 `createQuery` 的 `enabled` 访问器捕获,查询被自动激活,`queryFn` 开始执行。
- TanStack Query 负责管理后续的状态变化(`isFetching`, `data`, `error`),并驱动 `children` 片段内的 UI 自动更新。

#### 参考

- https://runed.dev/docs/utilities/use-intersection-observer
- https://tanstack.com/table/latest/docs/introduction