发布于 2026-01-06 0 阅读
0

乐观的用户界面

乐观的用户界面

这是本系列的第三篇文章(假设您已阅读前两篇文章)。本文的代码在这里。

  1. Redux 作为有限状态机
  2. Redux中的副作用
  3. 乐观的用户界面
  4. 我创造了一个怪物

新要求:多步骤表单

需求总是会变化的。现在我们需要实现一个多步骤表单:用户在第一页输入数据,在第二页选择商品,在第三页要求提供凭证或支付信息。

显示项目列表的组件是无状态的,因此将其移动到下一页非常简单;还需要从 Redux 读取状态并将其传递给项目列表组件;最后但同样重要的是,我们需要将用户导航到下一页——我们可以将其作为副作用来实现:

case "SUBMIT_FRUIT_OK":
  const navigateToTheNextPage = Cmd.run(path => history.push(path), {
    args: ["/step-2"]
  });
  return loop(
    { ...state: "fruit_ok", ... },
    navigateToTheNextPage
  );

问题在于,用户按下按钮后,应用程序会等待响应才能执行下一步操作。从用户的角度来看,这会让人感觉按钮没有反应,应用程序运行缓慢或出现故障(尤其是在响应时间超过约 200 毫秒的情况下)。

乐观的用户界面

解决此问题的一种方法是,不要等待,而是像我们已经收到响应一样继续进行,过渡到下一页,开始绘制页面(标题,可能是面包屑等),一旦我们到达实际内容,就绘制一个旋转指示器*(如果请求正在等待),否则绘制内容本身。

* - 如果延迟 200 毫秒,我们会给请求更多时间(用户“感觉不到”),然后再承认请求耗时过长。我不确定最初 200 毫秒这个数字是怎么来的,另一种观点认为应该是 100 毫秒

我们可以这样做:

case "SUBMIT_FRUIT":
  const navigateToTheNextPage = Cmd.run(path => history.push(path), {
    args: ["/step-2"]
  });
  return loop(
    { ...state: "fruit_loading", ... },
    Cmd.list([submitForm, navigateToTheNextPage])
  );

此外,我们还需要调整错误处理方式:

case "SUBMIT_FRUIT_ERROR":
  const navigateToPreviousPage = Cmd.run(
    (expectedPath, path) => {
      // check that user hasn't navigated away
      if (history.location.pathname === expectedPath)
        history.replace(path);
    },
    {
      args: ["/step-2", "/"]
    }
  );
  return loop(
    { ...state: "fruit_error", ... },
    navigateToPreviousPage
  );

并从“OK”情况中移除一个效果:

case "SUBMIT_FRUIT_OK":
  return loop(
    { ...state: "fruit_ok", ... },
    Cmd.none
  );

预取

好的,情况有所改善,但我们不能就此止步。如果我们能在获得有效数据后立即发起请求,例如,不要等待用户实际按下按钮,而是在用户提供有效数据后立即发送请求,就能节省几毫秒的时间。

为了模拟现实生活中的网络,我创建了setupProxy.js一个能够提供随机较慢响应(至少 100 毫秒)的网络。

何时触发预取

问题在于如何捕捉用户完成输入到提交表单之间的时间。

当用户鼠标接近表单末尾时

当用户鼠标接近表单末尾时运行预取。此功能不适用于移动设备。

<div
  onMouseEnter={() => {
    this.validateAndPrefetch(this.state.values);
  }}
>
  <button type="submit">Search</button>
</div>

变更

在每个输入框内容改变时执行预取操作。这种方法对于文本字段(输入框和文本区域)来说存在问题,因为它会产生大量的请求,而浏览器同时处理的请求数量有限,因此会降低最终的性能。不过,对于离散的输入框,例如下拉框/组合框、复选框、日历等,这种方法可能有效。

handleChange = (e: SyntheticEvent<HTMLInputElement>) => {
  const { name, value, type } = e.target;
  const values = {
    ...this.state.values,
    [name]: value
  };
  this.setState({ values });
  if (isDiscrete(type)) this.validateAndPrefetch(values);
};

模糊

在失去焦点时运行预取,这只有在表单中的所有字段都是必填项时才有效,在这种情况下,失去焦点和表单提交之间很可能不会有停顿。

handleBlur = (e: SyntheticEvent<HTMLInputElement>) => {
  const { name, type } = e.target;
  const [errors] = validate(this.state.values);
  this.setState({
    touched: {
      ...this.state.touched,
      [name]: true
    },
    errors
  });
  if (!isDiscrete(type)) this.validateAndPrefetch(values);
};

如何缓存结果

浏览器缓存

最简单的选择是依靠浏览器缓存,例如启动请求,并将结果“通过管道传递给dev/null”。

优点:使用服务器指定的缓存时间。

缺点:如果请求速度较慢,可能会出现预取数据在用户提交之前没有足够的时间被缓存的情况。

const baseFruitRequest = (form: FruitForm) => {
  //...
  const query = `name=${form.name}&start=${form.start.toISOString()}`;
  return request(`${endpoint}?${query}`);
};

export const prefetch = async (form: FruitForm): Promise<void> => {
  try {
    await baseFruitRequest(form);
  } catch (e) {}
};

LRU 在“JS 领域”

使用基于 LRU 的小型缓存来缓存获取请求。

缺点是这是额外的缓存,与服务器指令(缓存头)相比,可能会配置错误。

专业人士会优先处理慢速请求,用户后续提交的内容将从缓存中获取。

LRU 算法应该使用什么?

  • 我创建了一个lru_map 的分支,功能极其精简(使用双向链表和 Map 实现)。
  • 还有一种更小的实现方式——tmp -cache(使用数组和 Map 实现)。
const cache = new Cache<string, Promise<FruitResponse>>({
  max: 5,
  maxAge: 60000
});

export const fruitRequest = (form: FruitForm): Promise<FruitResponse> => {
  const query = queryToString(form);
  let result = cache.get(query);
  if (!result) {
    result = baseFruitRequest(form);
    cache.set(query, result);
  }
  return result;
};

export const prefetch = async (form: FruitForm): Promise<void> => {
  try {
    await fruitRequest(form);
  } catch (e) {}
};

关于预取的更多思考

根据我的个人实验,我发现预取可以在 300 毫秒到秒的时间内取得优势。

但胜利也是有代价的——我们破坏了封装性。连接器组件原本只负责分发 action,逻辑封装在 Redux 中,但现在逻辑也暴露给了连接器。

CORS

我们尝试通过预取来“取巧”地节省几毫秒,但同时,如果请求需要进行预检 CORS 检查,则可能会给最终体验增加几秒的延迟(具体取决于网络状况)。因此,在采用预取方案之前,最好先优化 CORS 检查。

如果端点是公开的且不需要身份验证(例如,任何人都可以读取,并且它并非特定于某个用户),则应移除 CORS。在这种情况下,CORS 无法提供任何安全保障。如果您要fetch移除所有自定义标头并使用传统的 GET/POST 请求,则浏览器会触发 CORS 预检。

如果使用 CORS,请确保浏览器可以缓存它(Access-Control-Max-Age)。

何时使用

能力越大,责任越大

乐观的用户界面 预取
GET-ish 是的 是的
后现代 或许

GET-ish

类似 GET 的请求——这种请求不会对服务器产生任何副作用,只读取数据。通常情况下,这是 GET 请求(但不限于此)。

如果我们确保降低客户端的错误率,例如在客户端验证用户输入后再发送,那么就可以应用乐观的用户界面,这样服务器错误响应率就会下降。

后现代

POST 请求——会对服务器产生副作用的请求,例如写入数据库等。通常包括 POST、PUT 或 DELETE 请求。

如果我们确保降低客户端的错误率,例如在客户端验证用户输入后再发送,那么就可以应用乐观的用户界面,这样服务器错误响应率就会下降。

有些特殊情况会阻止使用乐观 UI,例如 Braintree 托管字段,我们无法控制这些字段,并且在返回并再次显示表单时,如果出现错误,我们也无法重新填充这些字段。

预取:未经用户明确同意,我们不能向服务器发送“写入”请求。

例如,我们不能在用户点击“注册”之前提交注册表单,但同时我们可以预先验证所有字段(包括电子邮件、电话号码、密码),并使用乐观用户界面。

照片由 Jonas Verstuyft 拍摄,来自 Unsplash。


请在推特GitHub上关注我

文章来源:https://dev.to/stereobooster/optimistic-ui-5f1f