创建应用代码
接下来,我们要用Valito来 创建一个 小型应用。
首先,我们要定义一个Todo类型:
js
type Todo = {
id: stirng;
title: string;
done: boolean;
}
一个Todo 成员,由 类型为 string 的id,类型 为 stirng 的 title 和 类型 为 boolean 的 done 组成。
当我们在定义状态对象时,可以用Todo 类型:
js
const state = proxy<{ todos: Todop[] }>({
todos: []
})
这个初始的状态对象,被一个proxy所包裹。
为了操作 状态对象,我们需要 定义 一些 函数:添加 待办事项的 方法,删除待办事项的 方法,以及改变待办事项状态的方法:
js
const createTodo = (title: string) => {
state.todos.push({
id: nanoid(),
title,
done: false
})
}
const removeTodo = (id: string) => {
const index = state.todos.findIndex(
(item) => item.id === id
)
state.todos.splice(index, 1);
}
const togggleTodo = (id: string) => {
const index = state.todos.findIndex(
(item) => item.id === id
)
state.todos[index].done = !state.todos[index].done;
}
nanoid 是一个用于生成唯一id的函数。注意,这三个方法都是基于常规的JS语法实现的。这个三个方法把状态当成了普通的JS对象。而这是由proxy来实现的。
接下来要实现的,是TodoItem
组件。TodoItem
组件是一个通过不同样式来标识是否完成的 一个 勾选框 和 一个 删除按钮:
js
const TodoItem = ({
id,
title,
done,
}: {
id: string,
title: string,
done: boolean
}) => {
return (
<div>
<input
type="checkbox"
checked={done}
onChange={() => toggleTodo(id)}
/>
<span
style={{
textDecoration: done ? "line-through" : "none",
}}
>
{title}
</span>
<button onClick={() => removeTodo(id)}>
Delete
</button>
</div>
)
};
const MemoedTodoItem = memo(TodoItem);
注意,这个组件的属性并不是 整个 状态对象,而是 有三个属性,分别是 id、title 和 done属性。这是因为我们使用了memo函数,来创建 MemoedTodoItem
组件。状态使用收集函数,会检测状态属性。而如果我们传递给缓存组件的是一个 对象,那么将无法追踪其可访问性。
为了使用 MemoedTodoItem
组件,TodoList
组件需要使用 useSnapshot
来获取状态:
js
const TodoList = () => {
const { todos } = useSnapshot(state);
return (
<div>
{todos.map((todo) => (
<MemoedTodoITem
key={todo.id}
id={todo.id}
title={todo.title}
done={todo.done}
/>
))
}
</div>
)
}
这个组件以来自useSnapshot
的todos为状态,将之渲染为对应的组件。因此,useSnapshot
会因为任意一个数组发生变化,而触发重新渲染。这不是一个大问题,而且这是一个被验证可行的模式,因为MemoedTodoITem
组件只会在 id
, title
, 或 done
发生变化时而重新渲染。我们会在这一小节学习另一个模式。
接下来要实现的,是用于新增待办事项的 新增按钮:
js
const NewTodo = () => {
const [text, setText] = useState("");
const onClick = () => {
createTodo(text);
setText("");
};
return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button onClick={onClick} disbale={!text}>
Add
</button>
</div>
)
}
之后,我们要实现App组件:
js
const App = () => {
<>
<TodoList />
<NewTodo />
</>
}
让我们看看这个 应用 是如何工作的:
1.首先,它只有一个文本输入框和一个新增按钮:

2.如果我们点击了新增按钮,可以新增一个待办事项:

3.我们可以尽情地添加待办事项:

4.点击勾选框,可以改变事件的状态:

5.点击删除按钮,就可以删除一个待办事项

这个应用目前是运作良好的,但是我们还是改善该应用以减少不必要的重新渲染。当我们改动一个已存在的状态的状态时,不仅是对应的 TodoItem
组件会重新渲染,而且TodoList
组件也会重新渲染。目前来说,这还不是一个大问题,因为这还是一个清零的应用。
我们还要另一个方法来减少 TodoList
组件 不必要的额外重新渲染。但这不意味着我们整体上提升了应用到性能。至于采用哪一种方法,要视具体条件而定。
在另一种方式中,我们会在每一个 TodoItem
组件 中使用 useSnapshot
钩子。这个TodoItem
组件只接收id属性。这是调整后的TodoItem
组件:
js
const TodoItem = ({ id }: { id: string }) => {
const todoState = state.todos.find(
(todo) => todo.id === id
);
if (!todoState) {
throw new Error("invalid todo id");
}
const { title, done } = useSnapshot(todoState);
return (
<div>
<input
type="checkbox"
checked={done}
onChange={() => toggleTodo(id)}
/>
<span
style={{
textDecoration: done ? "line-through" : "none",
}}
>
{title}
</span>
<button onClick={() => removeTodo(id)}>
Delete
</button>
</div>
)
};
const MemoedTodoItem = memo(TodoItem);
我们会用id属性来找到对应的 todoState,通过 useSnapshot
来获取 todoState
,并获得相应的 title 和 done 属性。这个组件 只会 在 id,title 或者 done 属性变化时才 重新渲染。
现在,让我们看看 更新后的的 TodoList
组件。现在,我们只要传递id即可
js
const TodoList = () => {
const { todos } = useSnapshot(state);
const todoIds = todos.map((todo) => todo.id);
return (
<div>
{todoIds.map((todoId) => (
<MemoedTodoItem = key={todoId} id={todoId} />
))}
</div>
);
};
所以,todoIds 是每个todo对象的子集。这个组件只会在 todoIds的顺序发生变化时,才进行重新渲染。如果只是一个已存在的列表项的状态发生了变化,这个组件并不会重新渲染。因此,这个模式减少了额外的重新渲染。
在中型应用程序中,这两种方法在性能方面的变化是微妙的。这两种方法对于不同的编码模式更具意义。开发人员可以选择更符合其思维模式的方法。
在这个小节,我们通过一个简单的应用,学习了 useSnapshot
钩子的用法。接下来,我们要学习这个库的优点与缺点。
该库的优点与缺点
我们已经知道了 Valito的工作原理。那么首要问题是,何时该使用它,何时不该使用它。
第一个方面,是心智模型。我们有两个状态更新模型。一个是不可变更新方式,另一是可变更新方式。尽管JS本身是允许可变式更新的,React本身是基于不可变状态来构建的。因此,如果我们把这两个模式混合在一起,我们要确保自己没有弄混。一个可行的方案是,把 Valtio 状态 和 React状态 隔离开,以确保我们不会搞混。
可变更新的好处是,我们可以使用原声的JS语法。
比如说,我们可以通过index来删除一个数组成员:
js
array.splice(index, 1)
如果是不可变更新,就要麻烦一点:
js
[...array.slice(0, index), ...array.slice(index + 1)]
另一场景,是处理深层嵌套对象。如果是基于可变式更新,只要一行代码即可:
js
state.a.b.c.text = "hello";
如果是不可变更新,得写这么一坨:
js
{
...state,
a: {
...state.a,
b: {
...state.a.b,
},
c: {
...state.a.b.c,
text: "hello"
}
}
}
这样写确实很复杂。Valito能帮助我们更好的更新复杂的对象。
Valtio 还借助基于代理的渲染优化来帮助减少应用程序代码量。假设我们有一个包含 count
和 text
属性的状态,如下所示:
js
cons state = proxy({ count: 0, text: "hello"});
如果我们要做组件中使用 count,我们可以这样写Valito代码:
js
const Component = () => {
const { count } = useSnapshot(state);
return <>{count}</>
}
相比之下,Zustand需要这么写:
js
const Component = () => {
const count = useStore((state) => state.count)
return <>{count}</>
}
差异其实很小,但是Zustand里出现了两次 count。
让我们看看这个场景:
js
const Component = ({ showText }) => {
const snap = useSnapshot(state);
return <>{snap.count} {showText ? snap.text : "" }</>
}
同样的功能,如果由 Zustand来实现,我们需要多次 useStore钩子:
js
const Component = ({ showText }) => {
const count = useStore((state) => state.count)
const text = useStore(
(state) => showText ? state.text : ""
)
return <>{count} {text}</>
}
这意味着,如果我们有更多的值,就需要更多的钩子了。
而该库的缺点,则在于这种模式的渲染优化,是很不可预测的。Proxy会在幕后优化渲染,但这也会提升定位问题的难度。所以有些人喜欢选择基于选择器的钩子。
总而言之,这世界上并没有一劳永逸的方案。使用哪个方法还是由开发者视具体情况而定。
在本章中,我们了解了一个名为 Valtio 的库,它广泛使用了代理(proxies)。我们通过示例学习了它的用法 ------ 它允许直接修改状态(mutating state),使用起来就像操作普通的 JavaScript 对象一样自然,而基于代理的渲染优化则有助于减少应用程序代码量。这种方法是否是一个好的选择,取决于开发人员的具体需求。
在下一章中,我们将学习另一个库 ------React Tracked。它是一个基于 Context 的库,和 Valtio 一样具备基于代理的渲染优化能力。