Todos:困难的部分
第一部分(共两部分)
作为一名后端/基础设施开发人员,在过去的几年里,我对前端开发的复杂性越来越敬佩。
总的来说,前端 JavaScript 开发初期遇到的许多问题都已得到解决。语言
特性变得更加完善,浏览器支持也更加一致,
现在大多数东西都能找到 TypeScript 类型定义,许多关键软件包也趋于稳定,升级也不再那么令人头疼了。
一旦 Ecmascript 拥有 Python 风格的默认忽略类型提示
(目前大多数转译器都这样做),以便更容易地与 typescript 互操作,那么 javscript,或者说 typescript,
可能会成为我最喜欢的语言。
但是,前端开发仍然非常难!
这也不难理解。Web应用程序的界面可能像IDE一样复杂,
数据交换层也可能像分布式数据库一样复杂。
最近我在数据交换层遇到的一个相对“简单”的问题很好地说明了这一点。和
大多数前端教程一样,它也是从一个待办事项(Todos)问题开始的。请看以下示例:
import React, { useCallback, useState } from "react"
interface Todo {
id: number
title: string
done: boolean
}
type IdType = Todo["id"]
const Todo = (props: { todo: Todo; remove: () => void; update: (todoId: IdType, updates: Partial<Todo>) => void }) => {
const { todo, remove, update } = props
return (
<div>
<input value={todo.title} onChange={(e) => update(todo.id, { title: e.target.value })} />
<button onClick={() => remove()}>Remove</button>
<input type="checkbox" checked={todo.done} onClick={() => update(todo.id, { done: !todo.done })} />
</div>
)
}
const Todos = () => {
const [todos, setTodos] = useState<Todo[]>([])
const [newTodo, setNewTodo] = useState<string | null>(null)
const createTodo = useCallback((todo: Todo) => setTodos((todos) => [...todos, todo]), [setTodos])
const updateTodo = useCallback(
(todoId: IdType, updates: Partial<Todo>) => setTodos((todos) => todos.map((t) => (t.id !== todoId ? t : { ...t, ...updates }))),
[setTodos]
)
const removeTodo = useCallback((todoId: IdType) => setTodos((todos) => todos.filter((t) => t.id !== todoId)), [setTodos])
return (
<div>
<div>
{todos.map((t) => (
<Todo key={t.id} todo={t} update={updateTodo} remove={() => removeTodo(t.id)} />
))}
</div>
<input />
{newTodo && (
<button
onClick={() => {
const newId = Math.random()
createTodo({ id: newId, title: newTodo, done: false })
setNewTodo(null)
}}
>
Add{" "}
</button>
)}
</div>
)
}
瞧,几行代码就搞定了待办事项的几乎所有 CRUD 操作。我们甚至可以更新标题
,还能把它们标记为已完成。真棒!我早就说过 React 很厉害。看看实现待办事项有多简单!
但它并没有保存到任何地方。这应该也不难解决。我们快速搭建一个符合
当前趋势的后端(显然是 GraphQL,为了方便起见,这里以 REST 为例)
,API 就准备就绪了。现在只需在前端更新几行代码:
const [todos, setTodos] = useState<Todo[]>([])
// Connect to our backend
const fetchData = useCallback(async () => {
const resp = await fetch("/todos")
setTodos(resp.data)
}, [setTodos])
// Fetch our todos on load
useEffect(() => {
fetchData()
}, [])
// our createTodos should now use the API methods
const createTodo = useCallback((todo: Todo) => {
const resp = await post("/todos", todo)
// refresh data
fetchData()
})
const updateTodos = useCallback((todo: Todo) => {
const resp = await patch("/todos", todo)
// refresh data
fetchData()
})
我们启动了它。大部分功能似乎都能正常工作,但用户界面有点卡顿。你看,我们的网络服务器运行在本地,所以
网络延迟几乎为零。我们的 API 响应时间为 40 毫秒,但感觉仍然不够“即时”,添加待办事项时用户界面会有轻微的闪烁,我们需要等待响应。 部署到生产环境后,随着网络延迟的增加,
这种情况只会更糟。
我们还注意到,在更新待办事项时,会出现严重的竞态条件,有时更新会返回一个过时的对象,
因为响应顺序混乱。这很合理,因为我们的异步 API 可以随时响应,所以如果请求
和响应顺序混乱,而我们又随意地发送它们,那么新数据就会乱序。
现在我们意识到我们面临两个大数据同步问题:
-
我们需要将数据与 DOM 同步,避免不必要的渲染。
-
我们需要将本地数据与后端服务器同步。
事实证明,这两个问题都相当棘手。而且我们几乎还没涉及到
高级富 Web 应用需求™的任何内容:
错误捕获
我们需要在 API 请求出错时通知用户。这种情况可能发生在任何
操作中,根据操作类型(初始加载或更新),我们需要采取不同的措施。
因此我们补充:
const [error, setError] = useState<string | null>(null)
const [initialLoadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
// For some toast or notification
toast.error("Unable to process request")
}, [error])
if (initialLoadError) {
return <div>{initialLoadError}</div>
} else {
// ... render component
}
但这对于我们本地系统意味着什么?如果在更新或删除过程中发生这种情况,我们该如何回滚用户界面?
加载画面
我们需要向用户展示他们的初始加载/查询等仍在进行中。
加载过程也有不同的形式。加载初始数据时,我们希望在
渲染区域显示完整的加载指示器,而进行数据更新时,我们只需要在角落显示加载指示器即可。
更多亮点:
const [loading, setLoading] = useState<"initial" | "partial" | null>("initial")
if (initialLoadError) {
return <div>{initialLoadError}</div>
} else if (loading === "initial") {
return (
<div>
<LoadSpinner />
</div>
)
} else {
;<div style="position:relative">
{loading === "partial" && (
<div style="position: absolute; top: 0; right: 0">
<LoadSpiner />
</div>
)}
// ... render rest of component{" "}
</div>
}
防抖动
用户打字速度很快,我们不可能每次按键都作为 API 请求发送。解决这个问题的自然方法是
添加防抖功能:
const updateTodosDebounced = useDebounce(updateTodos, 2000, { trailing: true }, [updateTodos])
等等,我想要尾随还是前导?嗯。我们添加了这个,但用户输入时仍然会出现一些奇怪的回滚行为(
这是由于请求竞争造成的)。差不多了。
合成本地数据(乐观用户界面)
我们决定通过创建合成本地状态来解决闪烁问题。具体来说,我们会临时将数据添加到本地合成
数组中,该数组包含来自 API 的现有数据以及尚未持久化的本地变更。
这很棘手,因为很难确定哪个数据更新鲜(参见上面提到的竞争条件)。
我们来尝试一个足够好的解决方案:
const [todos, setTodos] = useState<Todo[]>([])
const [deletedTodos, setDeletedTodos] = useState<string[]>([])
const [localTodos, setLocalTodos] = useState<Todo[]>([])
// mergeTodos is left as an (complex) excercise for the reader
const syntheticTodos = useMemo(() => mergeTodos(todos, localTodos, deletedTodos), [todos, localTodos, deletedTodos])
假设我们删除了某些内容,我们会将该 ID 添加到已删除的待办事项列表中,这样mergeTodos在生成合成结果时,该条目就会被删除
。该函数还会将任何变更合并到待办事项列表中,例如:todo = {...todo, ...localTodo}
我们的合成阵列显著减少了闪烁现象。现在一切都感觉是瞬时的。我们不太确定
合并函数的逻辑,正如你所看到的,它仍然不具备抗竞争性。
另外,如果与合成更新相关的 API 操作失败怎么办?我们如何回滚?
离线工作、重试和网络故障逻辑:
我们在飞机上,发现没有 Wi-Fi 时,应用程序运行不正常。
由于我们人为地更改了数据,导致出现了一些实际上并未持久化的虚假变更。
我们最喜欢的 Web 应用会在后端连接中断时发出通知,并暂停新的操作,或者
允许我们离线工作,以便稍后同步。
我们决定采用前者(虽然有点取巧,但速度更快):
const [networkOffline, setNetworkOffline] = useState(navigator.onLine)
useEffect(() => {
const updateOnlineStatus = () => {
setNetworkOffline(navigator.onLine)
}
window.addEventListener("online", updateOnlineStatus)
window.addEventListener("offline", updateOnlineStatus)
return () => {
window.removeEventListener("online", updateOnlineStatus)
window.removeEventListener("offline", updateOnlineStatus)
}
}, [])
我们在各处添加了一些逻辑开关,以避免在离线状态下进行更新和更改。
我们意识到需要一些用户界面元素,以便用户查看初始加载数据或完全屏蔽这些数据。
撤销逻辑
现在我们不禁要问,Figma 到底是怎么cmd-z实现的?这需要对本地操作顺序有全面的了解,并且需要
非常巧妙地同步后端数据。
算了,用户现在不需要 cmd-z,以后我们会想办法把它整合到其他功能
里的。
实时重新装弹和多用户协作
谁会不用协作功能就使用待办事项应用呢?当其他用户修改待办事项时,修改应该在本地同步
更新我们的用户界面,这样我们就不会覆盖他们的更改。我们了解过 CRDT(变更响应表),但感觉有点过度设计了。
好吧,我们还是用更简单的方法吧:
// Update our data every few seconds
useEffect(() => {
const interval = setInterval(() => {
fetchData()
}, 5000)
return () => {
clearInterval(interval)
}
}, [])
显然,这会造成一些竞争条件和数据覆盖,但为什么我们的用户
一开始就要在 5 秒内协作处理同一个待办事项呢?他们不应该这样做。
数据缓存
为什么不将上次获取的数据存储在本地,以便在加载新数据的同时加载它呢?
或许可以这样写:
const [todos, setTodos] = useState<Todo[]>()
// Load initial data from local storage
useEffect(() => {
const res = localStorage.getItem("todo-cache")
if (res) {
setTodos(JSON.parse(res))
}
}, [])
// Update our todo cache everytime todos array changes
useEffect(() => {
localStorage.setItem("todo-cache", JSON.stringify(todos))
}, [todos])
我们需要根据查询语句对缓存查询进行键值对,并且还需要在用户注销时使非常旧的数据过期。
查询重用和双向数据绑定。
如果在页面上完全不同的组件中使用类似的查询,我们应该绑定与
之前查询相同的结果/更新。如果待办事项在多个位置渲染或可以在多个位置编辑,则数据应
在两个组件之间实时同步。这需要提升状态。我们暂时跳过这一步。
钩汤和现成工具
目前,我们的代码Todo.tsx已经有大约40 个钩子和12 个组件。所有这些都是为了实现一些简单的待办事项的半吊子、
故障频出的 CRUD 功能。
我们的依赖项数组非常混乱,最近有人报告说有一个不稳定的 API 请求每 10 毫秒就会触发一次。
我们查看了 git blame 信息,发现有人向
依赖项数组中添加了不应该添加的内容(公平地说,Eslint 盲目地警告他们添加了该内容)。
肯定有人已经解决这个问题了……
我们的说法是正确的,这取决于我们关心问题的哪一部分。
问题 1:将数据绑定到 DOM/React
首先,我们来看解决 DOM 数据绑定问题的方法。方法有很多:
- React Hooks:入门级工作很棒,但一旦开始引入所有这些功能,就会变成一团糟。要在15个组件中串联这些状态变量,简直就是一场噩梦。
- Redux:看起来很棒。它使用的事件流非常适合我们可能需要的撤销/回滚逻辑。但试用之后,我们发现分散在多个 reducer 文件中的带外副作用并不清晰。访问全局状态很困难,API 请求也很奇怪……thunk 到底是什么鬼?
- Mobx:哇,这看起来真简单。我们创建一个类,把需要重新渲染的变量标记为可观察对象,一切看起来都很简洁明了。Facebook 在 WhatsApp 上就用了它。这里完全没有 Redux 的事件流之类的东西。我们要么在特定时间点对类进行快照,要么自己实现一个解决方案。
- XState:有限状态机(FSM)很棒。我们已经在一些后端流程中成功使用过几次。我们快速搭建了一个示例,然后意识到这台机器变得非常复杂。流程很多,像回滚/部分加载之类的机制也变得难以理解。或许我们应该让 FSM 中的主要逻辑和子渲染逻辑保持独立?
试用了几个之后,我们最终选择了mobx。它的封装机制有点复杂,但我们发现 98% 的情况下
这种“魔法”都非常有效。observers虽然到处都用 mobx 有点麻烦,但我们了解到它通过只监听组件中使用的字段(相当于对每个组件进行记忆化)来最大限度地减少重新渲染
,所以我们觉得值得。
问题 2:将数据绑定到后端
现在我们已经解决了数据绑定问题,接下来需要解决后端同步问题。
这里也有很多选择:
- useSWR:一个 React 数据获取 API,它处理许多组件,如缓存、状态(加载/错误/结果)、乐观 UI 支持,而且我们必须非常统一的 REST。
- Apollo GraphQL 客户端:这个强大的库内置了很多实用功能。硬性要求是必须使用 GraphQL。
- 基本获取:使用原生浏览器 API 发出请求并自行管理状态。
- 云存储客户端,例如 Firebase:许多云 API 都带有 SDK 和 React 数据绑定,例如 Google Firebase。
我们的 API 不是 GraphQL(也许应该用 GraphQL?),所以我们最终选择了useSWR。这个库只能满足我们部分
高级富 Web 应用™ 的需求。
问题 2.5:连接这两个部分:
遗憾的是,我们用于获取数据的库与用于同步数据的库也高度关联。以useSWR
为例,我们不得不采用其基于 hook 的系统,或者需要在我们 自己的状态管理系统中创建一些绑定。
所以,我们或多或少都会被纳入某种框架。
后续步骤
到了这一步,我们或许就能满足于现成的工具了。
我们选取一些工具,编写一些粘合代码,然后开始使用。
对于回滚和网络状态等需要处理的功能,我们会添加一些临时逻辑来妥善处理。
但我们并不完全满意。Todos这只是我们应用程序中的一个数据模型。我们可能还会有30多个,在所有这些模型中重复使用相同的拼凑式钩子和方法会非常糟糕。此外, 一旦这些半吊子的钩子散落在各处,
以后需要添加新功能时也会变得非常困难。
我们的团队规模足够大,这个问题也足够棘手。让我们做一件看似不可能的事:推出我们自己的解决方案。
下次预告:在下一篇博文中(希望下周就能发布),我将介绍如何创建一个满足我们
高级富 Web 应用™ 大部分需求的前端事务日志。我们将实现一个尽可能线性化操作并提供
修改和回滚机制的日志,同时保持组件逻辑的简洁性。我们将事务管理器实现为一个通用类型,
以便我们可以将其用于Todos任何其他需要的类型。
想在文章发布时收到通知吗?请关注我们的RSS、Twitter或订阅我们的邮件列表。
文章来源:https://dev.to/pnegahdar/todos-the-hard-parts-3ij2