乐观的用户界面
这是本系列的第三篇文章(假设您已阅读前两篇文章)。本文的代码在这里。
新要求:多步骤表单
需求总是会变化的。现在我们需要实现一个多步骤表单:用户在第一页输入数据,在第二页选择商品,在第三页要求提供凭证或支付信息。
显示项目列表的组件是无状态的,因此将其移动到下一页非常简单;还需要从 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。
文章来源:https://dev.to/stereobooster/optimistic-ui-5f1f