跳到主要内容

Data Client

响应式变更

使用 useSuspense() 渲染数据,然后使用 Controller.fetch() 进行变更。

这会原子化地且立即更新所有用到它的地方,无需任何额外请求。Reactive Data Client 自动确保全局的数据一致性和完整性,即使是最棘手的竞态条件也不例外。

实时编辑器
import { UserResource } from './resources';

export default function ProfileEdit({ id }: { id: number }) {
  const user = useSuspense(UserResource.get, { id });

  const controller = useController();
  const handleChange = ({ currentTarget: { value: name } }) =>
    controller.fetch(UserResource.partialUpdate, { id }, { name });

  return (
    <TextInput
      label="Name"
      value={user.name}
      onChange={handleChange}
    />
  );
}
结果
Store▶
实时编辑器
import { UserResource } from './resources';

export default function ProfileEdit({ id }: { id: number }) {
  const { user } = useSuspense(UserResource.get, { id });

  const controller = useController();
  const handleChange = ({ currentTarget: { value: name } }) =>
    controller.fetch(UserResource.update, { id, name });

  return (
    <TextInput
      label="Name"
      value={user.name}
      onChange={handleChange}
    />
  );
}
结果
Store▶

结构化数据

即使数据变得越来越复杂,数据一致性、性能和类型安全依然可以扩展。

创建和删除会响应式地更新正确的列表,即使这些列表嵌套在其他对象中。

借助多态和无界对象/映射支持,为最复杂的数据建模。

实时编辑器
import { TodoResource } from './resources';

export default function NewTodo({ userId }: { userId: number }) {
  const controller = useController();
  const handleKeyDown = async e => {
    if (e.key === 'Enter') {
      controller.fetch(TodoResource.getList.push, {
        userId,
        title: e.currentTarget.value,
      });
      e.currentTarget.value = '';
    }
  };
  return (
    <div className="listItem nogap">
      <label>
        <input type="checkbox" name="new" checked={false} disabled />
        <TextInput size="small" onKeyDown={handleKeyDown} />
      </label>
      <CancelButton />
    </div>
  );
}
结果
Store▶

实时更新

使用 useLive() 与远程变化保持同步。

轮询、SSE 和 Websocket,或通过 中间件 支持自定义协议

实时编辑器
import { getTicker } from './resources';

export default function AssetPrice({ symbol }: { symbol: string }) {
  const productId = `${symbol}-USD`;
  const ticker = useLive(getTicker, { productId });
  return (
    <tr>
      <th>{symbol}</th>
      <td align="right">
        <NumberFlow
          value={ticker.price}
          format={{ style: 'currency', currency: 'USD' }}
        />
      </td>
    </tr>
  );
}
结果
Store▶
实时编辑器
import type {
  Controller,
  Manager,
  Middleware,
} from '@data-client/react';
import { actionTypes, getDefaultManagers } from '@data-client/react';
import { ReconnectingSocket } from './socket';
import { Ticker } from './resources';

const { SUBSCRIBE, UNSUBSCRIBE } = actionTypes;

interface Product {
  channel: string;
  count: number;
}

/** Pushes Coinbase prices into the store over one socket */
export class StreamManager implements Manager {
  protected socket = new ReconnectingSocket(
    'wss://ws-feed.exchange.coinbase.com',
  );
  declare protected controller: Controller;
  protected products = new Map<string, Product>();

  middleware: Middleware = controller => {
    this.controller = controller;
    return next => async action => {
      if (
        (action.type === SUBSCRIBE || action.type === UNSUBSCRIBE) &&
        'channel' in action.endpoint
      ) {
        const { productId } = action.args[0];
        if (action.type === SUBSCRIBE) {
          const { channel } = action.endpoint as { channel: string };
          this.subscribe(productId, channel);
        } else this.unsubscribe(productId);
        return;
      }
      return next(action);
    };
  };

  /** Shares one socket subscription among a product's components */
  protected subscribe(productId: string, channel: string) {
    const product = this.products.get(productId);
    if (product) {
      product.count++;
    } else {
      this.products.set(productId, { channel, count: 1 });
      this.send('subscribe', channel, productId);
    }
  }

  protected unsubscribe(productId: string) {
    const product = this.products.get(productId);
    if (product && --product.count === 0) {
      this.products.delete(productId);
      this.send('unsubscribe', product.channel, productId);
    }
  }

  init() {
    this.socket.onopen = () => {
      // a new socket has no subscriptions yet
      for (const [productId, { channel }] of this.products)
        this.send('subscribe', channel, productId);
    };
    this.socket.onmessage = ({ type, product_id, price, time }) => {
      if (type !== 'ticker') return;
      this.controller.set(
        Ticker,
        { product_id },
        { product_id, price, time },
      );
    };
    // without subscriptions, quiet is expected
    this.socket.expectsMessages = () => this.products.size > 0;
    this.socket.open();
  }

  cleanup() {
    this.socket.close();
  }

  protected send(
    type: 'subscribe' | 'unsubscribe',
    channel: string,
    productId: string,
  ) {
    this.socket.send({
      type,
      product_ids: [productId],
      channels: [channel],
    });
  }
}

// passed to <DataProvider managers={getManagers()}>
export default function getManagers() {
  return [new StreamManager(), ...getDefaultManagers()];
}
/** A WebSocket that reconnects after drops, going offline and silence,
 * and pauses while the page is hidden */
export class ReconnectingSocket {
  onopen = () => {};
  onmessage = (message: any) => {};
  /** Whether silence means a dead connection */
  expectsMessages = () => true;

  declare protected socket: WebSocket;
  protected attempts = 0;
  declare protected openedAt: number | undefined;
  /** Pending reconnect, or the watchdog while connected */
  declare protected timer: ReturnType<typeof setTimeout>;

  constructor(protected url: string) {}

  open() {
    if (!document.hidden) this.connect();
    addEventListener('online', this.reconnect);
    // a socket can take minutes to notice the network is gone
    addEventListener('offline', this.stop);
    document.addEventListener('visibilitychange', this.onVisibility);
  }

  close() {
    removeEventListener('online', this.reconnect);
    removeEventListener('offline', this.stop);
    document.removeEventListener(
      'visibilitychange',
      this.onVisibility,
    );
    this.stop();
  }

  /** Dropped until open; onopen is the place to (re)send state */
  send(message: object) {
    if (this.socket?.readyState === WebSocket.OPEN)
      this.socket.send(JSON.stringify(message));
  }

  protected connect() {
    this.stop();
    this.socket = new WebSocket(this.url);
    this.openedAt = undefined;
    this.socket.onopen = () => {
      this.openedAt = Date.now();
      this.onopen();
    };
    this.socket.onmessage = event => {
      this.watch();
      this.onmessage(JSON.parse(event.data));
    };
    // after a failed connect, an error or a server close
    this.socket.onclose = () => this.retry();
    this.watch();
  }

  /** A socket can stay open on a dead network (like after sleep),
   * so reconnect when messages stop arriving */
  protected watch() {
    clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      if (this.expectsMessages()) this.retry();
      else this.watch();
    }, 30_000);
  }

  /** Reconnects after a delay that doubles with each attempt, until a
   * socket stays up long enough to count as working */
  protected retry() {
    this.stop();
    if (
      this.openedAt !== undefined &&
      Date.now() - this.openedAt > 10_000
    )
      this.attempts = 0;
    const delay = Math.min(30_000, 1000 * 2 ** this.attempts);
    this.attempts++;
    this.timer = setTimeout(this.reconnect, delay);
  }

  protected reconnect = () => {
    if (
      !document.hidden &&
      this.socket?.readyState !== WebSocket.OPEN
    )
      this.connect();
  };

  /** Background tabs don't need prices */
  protected onVisibility = () => {
    if (document.hidden) this.stop();
    else this.reconnect();
  };

  /** Stops the socket without waiting for it to finish closing,
   * which it can't do offline */
  protected stop = () => {
    clearTimeout(this.timer);
    if (!this.socket) return;
    this.socket.onmessage = this.socket.onclose = null;
    this.socket.close();
  };
}
import { getTicker } from './resources';

export default function AssetPrice({ symbol }: { symbol: string }) {
  const productId = `${symbol}-USD`;
  const ticker = useLive(getTicker, { productId });
  return (
    <tr>
      <th>{symbol}</th>
      <td align="right">
        <NumberFlow
          value={ticker.price}
          format={{ style: 'currency', currency: 'USD' }}
        />
      </td>
    </tr>
  );
}
结果
Store▶

数据完整性

强大的自动推断类型;引用稳定的单一数据源确保一致性;异步不变量让你轻松避免竞态条件

性能

页面导航比 React 基线快 24 倍,比 TanStack Query 和 SWR 快 10 倍。变更速度快 92 倍,超越 TanStack Query、SWR 和 React 基线。

组合优于配置

在需要的地方声明你需要的数据。共享数据定义,跨平台、跨组件、跨协议、跨行为。

渐进式采用

只需一行数据定义和一行数据绑定即可快速上手。然后再添加 TypeScript、基于 Schemas 的规范化缓存、乐观更新等功能。

一个完整的应用

基于实时 GitHub API 的 GitHub issue 和 pull request 浏览器,使用 REST resource 和 Suspense 构建。

探索 github-app 示例

更多演示