第九章 案例 3 - Valtio 【下】

创建应用代码

接下来,我们要用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 还借助基于代理的渲染优化来帮助减少应用程序代码量。假设我们有一个包含 counttext 属性的状态,如下所示:

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 一样具备基于代理的渲染优化能力。

相关推荐
仟濹4 小时前
【HTML】基础学习【数据分析全栈攻略:爬虫+处理+可视化+报告】
大数据·前端·爬虫·数据挖掘·数据分析·html
小小小小宇5 小时前
前端WebWorker笔记总结
前端
小小小小宇5 小时前
前端监控用户停留时长
前端
小小小小宇5 小时前
前端性能监控笔记
前端
烛阴6 小时前
Date-fns教程:现代JavaScript日期处理从入门到精通
前端·javascript
全栈小56 小时前
【前端】Vue3+elementui+ts,TypeScript Promise<string>转string错误解析,习惯性请出DeepSeek来解答
前端·elementui·typescript·vue3·同步异步
穗余6 小时前
NodeJS全栈开发面试题讲解——P6安全与鉴权
前端·sql·xss
小蜜蜂嗡嗡7 小时前
flutter项目迁移空安全
javascript·安全·flutter
穗余7 小时前
NodeJS全栈开发面试题讲解——P2Express / Nest 后端开发
前端·node.js
航Hang*7 小时前
WEBSTORM前端 —— 第3章:移动 Web —— 第4节:移动适配-VM
前端·笔记·edge·less·css3·html5·webstorm