• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

学了很多技术之后,这节来综合练习下,做个 Todo List。

当然,不是普通的那种,而是拖拽版:

可以拖拽右边的 Todo Item 到列表里:

拖拽到空白区域的时候,会高亮标出,松手后插入到该位置。

或者也可以拖动列表中的 TodoItem 调整顺序。

还可以拖到垃圾箱删除:

当拖动过来或者双击 TodoItem 的时候,可以进入编辑模式:

此外,Todo Item 勾选后代表完成:

技术栈用 react-dnd + zustand + tailwind + react-spring。

列表的数据都在 Store 里存储:

增删改之后修改 Store 里的数据。

用 React Dnd 来做拖拽。

用 react-spring 实现过渡动画。

样式使用 Tailwind 的原子化样式来写。

需求理清了,我们正式上手写:

npx create-vite

进入项目,去掉 StrictMode:

然后新建 TodoList/index.tsx 组件:

import { FC } from "react";

interface TodoListProps {}

export const TodoList: FC<TodoListProps> = (props) => {
    return <div></div>;
};

按照 tailwind 文档里的步骤安装 tailwind:

npm install -D tailwindcss postcss autoprefixer

npx tailwindcss init -p

会生成 tailwind 和 postcss 配置文件:

修改下 content 配置,也就是从哪里提取 className:

/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {},
    },
    plugins: [],
};

前面 tailwind 那节讲过,tailwind 会提取 className 之后按需生成最终的 css。

改下 index.css 引入 tailwind 基础样式:

@tailwind base;
@tailwind components;
@tailwind utilities;

安装 tailwind 插件之后:

在写代码的时候就会提示 className 和对应的样式值:

这个插件触发提示需要先敲一个空格,这点要注意下:

有的你不知道 className 叫啥的样式,还可以在 tailwind 文档里搜:

改下 TodoList 的样式:

import { FC } from "react";

interface TodoListProps {}

export const TodoList: FC<TodoListProps> = (props) => {
    return (
        <div className="w-1000 h-600 m-auto mt-100 p-10 border-2 border-black"></div>
    );
};

设置 width 1000,height 600,margin-top 100 padding 10 然后 border 2

在 App.tsx 引入下:

import { TodoList } from "./TodoList";

function App() {
    return <TodoList></TodoList>;
}

export default App;

把开发服务跑起来:

npm install

npm run dev

为啥部分样式没生效呢?

因为像 w-1000 h-600 mt-100 这种,在内置的 className 里并没有。

需要在 tailwind.config.js 里配置下:

/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {
            width: {
                1000: "1000px",
            },
            height: {
                600: "600px",
            },
            margin: {
                100: "100px",
            },
        },
    },
    plugins: [],
};

这样就好了:

然后继续写布局:

import { FC } from "react";

interface TodoListProps {}

export const TodoList: FC<TodoListProps> = (props) => {
    return (
        <div
            className={`
            w-1000 h-600 m-auto mt-100 p-10
            border-2 border-black
            flex justify-between items-start
        `}>
            <div className="flex-2 h-full mr-10 bg-blue-400 overflow-auto"></div>

            <div className="flex-1 h-full bg-blue-400"></div>
        </div>
    );
};

父元素 display:flex,然后 子元素分别 2 和 1 的比例,设置 margin-right:10px

这里 h-full 是 height:100%

flex-2 要配置下:

看一下:

你会发现 margin 和 padding 都不是 10px,而是 2.5rem

我们在 tailwind.config.js 里覆盖下:

/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {
            width: {
                1000: "1000px",
                600: "600px",
            },
            height: {
                600: "600px",
            },
            margin: {
                100: "100px",
                10: "10px",
            },
            padding: {
                10: "10px",
            },
            flex: {
                2: 2,
            },
        },
    },
    plugins: [],
};

这样就好了:

然后去掉背景颜色,添加 List、GarbageBin、NewItem 这三个组件:

import { FC } from "react";
import classNames from "classnames";
import { NewItem } from "./NewItem";
import { GarbageBin } from "./GarbageBin";
import { List } from "./List";

interface TodoListProps {}

export const TodoList: FC<TodoListProps> = (props) => {
    return (
        <div
            className={classNames(
                "w-1000 h-600 m-auto mt-100 p-10",
                "border-2 border-black",
                "flex justify-between items-start"
            )}>
            <div className="flex-2 h-full mr-10 overflow-auto">
                <List />
            </div>

            <div
                className={classNames(
                    "flex-1 h-full",
                    "flex flex-col justify-start"
                )}>
                <NewItem />
                <GarbageBin className={"mt-100"} />
            </div>
        </div>
    );
};

这里多行 className 换成用 classnames 包来写。

npm install --save classnames

分别添加 GarbageBin.tsx

import classNames from "classnames";
import { FC } from "react";

interface GarbaseProps {
    className?: string | string[];
}

export const GarbageBin: FC<GarbaseProps> = (props) => {
    const cs = classNames("h-100 border-2 border-black", props.className);

    return <div className={cs}></div>;
};

NewItem.tsx

import classNames from "classnames";
import { FC } from "react";

interface NewItemProps {
    className?: string | string[];
}

export const NewItem: FC<NewItemProps> = (props) => {
    const cs = classNames("h-200 border-2 border-black", props.className);

    return <div className={cs}></div>;
};

还有 List.tsx

import classNames from "classnames";
import { FC } from "react";

interface ListProps {
    className?: string | string[];
}

export const List: FC<ListProps> = (props) => {
    const cs = classNames("h-full border-2 border-black", props.className);

    return <div className={cs}></div>;
};

这里的 h-200、h-100 要在配置文件里加一下:

现在界面是这样的:

然后先来实现 List 组件部分:

import classNames from "classnames";
import { FC } from "react";

interface ListProps {
    className?: string | string[];
}

export const List: FC<ListProps> = (props) => {
    const cs = classNames("h-full p-10", props.className);

    return (
        <div className={cs}>
            <Item />
            <Item />
            <Item />
            <Item />
            <Item />
            <Item />
            <Item />
        </div>
    );
};

function Item() {
    return (
        <div
            className={classNames(
                "h-100 border-2 border-black bg-blue-300 mb-10 p-10",
                "flex justify-start items-center",
                "text-xl tracking-wide"
            )}>
            <input type="checkbox" className="w-40 h-40 mr-10" />
            <p>待办事项</p>
        </div>
    );
}

配置文件加一下 w-40、h-40 的配置:

看下效果:

里面用到的 className 可以去查 tailwind 文档。

然后是 NewItem 组件:

import classNames from "classnames";
import { FC } from "react";

interface NewItemProps {
    className?: string | string[];
}

export const NewItem: FC<NewItemProps> = (props) => {
    const cs = classNames(
        "h-100 border-2 border-black",
        "leading-100 text-center text-2xl",
        "bg-green-300",
        "cursor-move select-none",
        props.className
    );

    return <div className={cs}>新的待办事项</div>;
};

GarbageBin 组件:

import classNames from "classnames";
import { FC } from "react";

interface GarbaseProps {
    className?: string | string[];
}

export const GarbageBin: FC<GarbaseProps> = (props) => {
    const cs = classNames(
        "h-200 border-2 border-black",
        "bg-orange-300",
        "leading-200 text-center text-2xl",
        "cursor-move select-none",
        props.className
    );

    return <div className={cs}>垃圾箱</div>;
};

在配置文件里加一下两个 line-height:

/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {
            width: {
                1000: "1000px",
                600: "600px",
                40: "40px",
            },
            height: {
                600: "600px",
                200: "200px",
                100: "100px",
                40: "40px",
            },
            margin: {
                100: "100px",
                10: "10px",
            },
            padding: {
                10: "10px",
            },
            flex: {
                2: 2,
            },
            lineHeight: {
                100: "100px",
                200: "200px",
            },
        },
    },
    plugins: [],
};

其实这些 width、height、margin、padding 的值的覆盖可以统一放到 spacing 里:

/** @type {import('tailwindcss').Config} */
export default {
    content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
    theme: {
        extend: {
            spacing: {
                10: "10px",
                40: "40px",
                100: "100px",
                200: "200px",
                600: "600px",
                1000: "1000px",
            },
            width: {
                // 1000: '1000px',
                // 600: '600px',
                // 40: '40px',
                // 10: '10px'
            },
            height: {
                // 600: '600px',
                // 200: '200px',
                // 100: '100px',
                // 40: '40px',
                // 10: '10px'
            },
            margin: {
                // 100: '100px',
                // 10: '10px'
            },
            padding: {
                // 10: '10px'
            },
            flex: {
                2: 2,
            },
            lineHeight: {
                100: "100px",
                200: "200px",
            },
        },
    },
    plugins: [],
};

tailwind 文档里写了,很多样式都继承 spacing 的配置:

或者不想全局改默认配置,也可以用 text-[14px] 这种方式。

text-[14px] 就会生成 font-size:14px 的样式:

接下来加上 react-dnd 来做拖拽。

安装用到的包:

npm install react-dnd react-dnd-html5-backend

在 main.tsx 引入下 DndProvider

import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";

ReactDOM.createRoot(document.getElementById("root")!).render(
    <DndProvider backend={HTML5Backend}>
        <App />
    </DndProvider>
);

它是 react-dnd 用来跨组件传递数据的。

在 NewItem.tsx 组件里用 useDrag 添加拖拽:

import classNames from "classnames";
import { FC, useEffect, useRef } from "react";
import { useDrag } from "react-dnd";

interface NewItemProps {
    className?: string | string[];
}

export const NewItem: FC<NewItemProps> = (props) => {
    const ref = useRef<HTMLDivElement>(null);

    const [{ dragging }, drag] = useDrag({
        type: "new-item",
        item: {},
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });

    useEffect(() => {
        drag(ref);
    }, []);

    const cs = classNames(
        "h-100 border-2 border-black",
        "leading-100 text-center text-2xl",
        "bg-green-300",
        "cursor-move select-none",
        dragging ? "border-dashed bg-white" : "",
        props.className
    );

    return (
        <div ref={ref} className={cs}>
            新的待办事项
        </div>
    );
};

拖动过程中,设置 border 虚线、背景白色。

然后在 List 的 Item 也加上 useDrag 拖拽:

function Item() {
    const ref = useRef < HTMLDivElement > null;

    const [{ dragging }, drag] = useDrag({
        type: "list-item",
        item: {},
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });

    useEffect(() => {
        drag(ref);
    }, []);

    return (
        <div
            ref={ref}
            className={classNames(
                "h-100 border-2 border-black bg-blue-300 mb-10 p-10",
                "flex justify-start items-center",
                "text-xl tracking-wide",
                dragging ? "bg-white border-dashed" : ""
            )}>
            <input type="checkbox" className="w-10 h-10 mr-10" />
            <p>待办事项</p>
        </div>
    );
}

在垃圾箱添加 useDrop:

import classNames from "classnames";
import { FC, useEffect, useRef } from "react";
import { useDrop } from "react-dnd";

interface GarbaseProps {
    className?: string | string[];
}

export const GarbageBin: FC<GarbaseProps> = (props) => {
    const ref = useRef<HTMLDivElement>(null);

    const [{ isOver }, drop] = useDrop(() => {
        return {
            accept: "list-item",
            drop(item) {},
            collect(monitor) {
                return {
                    isOver: monitor.isOver(),
                };
            },
        };
    });

    useEffect(() => {
        drop(ref);
    }, []);

    const cs = classNames(
        "h-200 border-2 border-black",
        "bg-orange-300",
        "leading-200 text-center text-2xl",
        "cursor-move select-none",
        isOver ? "bg-yellow-400 border-dashed" : "",
        props.className
    );

    return (
        <div ref={ref} className={cs}>
            垃圾箱
        </div>
    );
};

accept 指定了 list-item,只有对应的 type 拖拽到这里才能触发 isOver:

那新的 todo item 拖到哪里呢?

到这里:

所以我们要把这些地方也新建个组件,然后添加 useDrop:

去掉之前 Item 的 mt-10 换成 Gap 的 h-10:

import classNames from "classnames";
import { FC, useEffect, useRef } from "react";
import { useDrag, useDrop } from "react-dnd";

interface ListProps {
    className?: string | string[];
}

export const List: FC<ListProps> = (props) => {
    const cs = classNames("h-full p-10", props.className);

    return (
        <div className={cs}>
            <Gap />
            <Item />
            <Gap />
            <Item />
            <Gap />
            <Item />
            <Gap />
            <Item />
            <Gap />
            <Item />
            <Gap />
            <Item />
            <Gap />
            <Item />
            <Gap />
        </div>
    );
};

function Gap() {
    const ref = useRef<HTMLDivElement>(null);

    const [{ isOver }, drop] = useDrop(() => {
        return {
            accept: "new-item",
            drop(item) {},
            collect(monitor) {
                return {
                    isOver: monitor.isOver(),
                };
            },
        };
    });

    useEffect(() => {
        drop(ref);
    }, []);

    const cs = classNames("h-10", isOver ? "bg-red-300" : "");

    return <div ref={ref} className={cs}></div>;
}

function Item() {
    const ref = useRef<HTMLDivElement>(null);

    const [{ dragging }, drag] = useDrag({
        type: "list-item",
        item: {},
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });

    useEffect(() => {
        drag(ref);
    }, []);

    return (
        <div
            ref={ref}
            className={classNames(
                "h-100 border-2 border-black bg-blue-300 p-10",
                "flex justify-start items-center",
                "text-xl tracking-wide",
                dragging ? "bg-white border-dashed" : ""
            )}>
            <input type="checkbox" className="w-40 h-40 mr-10" />
            <p>待办事项</p>
        </div>
    );
}

覆盖下 w-10、h-10 的值,默认是 rem,我们还是用 px:

现在 new-item 就能拖过来了:

现在 Gap 和 Item 代码挺多了,分离出去作为单独的模块 Gap.tsx 和 Item.tsx

接下来处理下具体的状态逻辑。

安装 zustand:

npm install --save zustand

创建 TodoList/Store.ts

import { create } from "zustand";

export interface ListItem {
    id: string;
    status: "todo" | "done";
    content: string;
}

type State = {
    list: Array<ListItem>;
};

type Action = {
    addItem: (item: ListItem) => void;
    deleteItem: (id: string) => void;
    updateItem: (item: ListItem) => void;
};

export const useTodoListStore = create<State & Action>((set) => ({
    list: [],
    addItem: (item: ListItem) => {
        set((state) => {
            return {
                list: [...state.list, item],
            };
        });
    },
    deleteItem: (id: string) => {
        set((state) => {
            return {
                list: state.list.filter((item) => {
                    return item.id !== id;
                }),
            };
        });
    },
    updateItem: (updateItem: ListItem) => {
        set((state) => {
            return {
                list: state.list.map((item) => {
                    if (item.id === updateItem.id) {
                        return updateItem;
                    }
                    return item;
                }),
            };
        });
    },
}));

state 就是 list,然后添加 addItem、deleteItem、updateItem 的方法。

在 List 组件里引入下:

传入 data,顺便指定 key:

import classNames from "classnames";
import { FC, Fragment } from "react";
import { Gap } from "./Gap";
import { Item } from "./Item";
import { useTodoListStore } from "./store";

interface ListProps {
    className?: string | string[];
}

export const List: FC<ListProps> = (props) => {
    const list = useTodoListStore((state) => state.list);

    const cs = classNames("h-full p-10", props.className);

    return (
        <div className={cs}>
            {list.length
                ? list.map((item) => {
                      return (
                          <Fragment key={item.id}>
                              <Gap />
                              <Item data={item} />
                          </Fragment>
                      );
                  })
                : "暂无待办事项"}
            <Gap />
        </div>
    );
};

<Fragment> 也可以写 <></>,它只是用来给多个 children 包一层,但不会生成 dom 节点。

在 Item 组件添加 content 参数:

看下效果:

我们加一下添加 item 的处理:

import classNames from "classnames";
import { useEffect, useRef } from "react";
import { useDrop } from "react-dnd";
import { useTodoListStore } from "./store";

export function Gap() {
    const addItem = useTodoListStore((state) => state.addItem);

    const ref = useRef < HTMLDivElement > null;

    const [{ isOver }, drop] = useDrop(() => {
        return {
            accept: "new-item",
            drop(item) {
                addItem({
                    id: Math.random().toString().slice(2, 8),
                    status: "todo",
                    content: "待办事项",
                });
            },
            collect(monitor) {
                return {
                    isOver: monitor.isOver(),
                };
            },
        };
    });

    useEffect(() => {
        drop(ref);
    }, []);

    const cs = classNames("h-10", isOver ? "bg-red-300" : "");

    return <div ref={ref} className={cs}></div>;
}

这里用 Math.random 生成 6 位的随机数:

然后加一下删除的处理:

drag 的时候加上传递的数据:

drop 的时候拿到 id 执行删除:

测试下:

删除也没问题。

然后加上编辑功能:

用两个 state 分别保存 editing 状态和 input 内容。

onDoubleClick 的时候显示 input,修改 editing 状态为 true。

onBlur 的时候修改 editing 状态为 false。

并且用 updateItem 更新状态:

没啥问题:

然后当选中 checkbox 的时候,也要 updateItem:

import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useDrag } from "react-dnd";
import { ListItem, useTodoListStore } from "./store";

interface ItemProps {
    data: ListItem;
}

export function Item(props: ItemProps) {
    const { data } = props;

    const updateItem = useTodoListStore((state) => state.updateItem);

    const ref = useRef<HTMLDivElement>(null);

    const [editing, setEditing] = useState(false);

    const [editingContent, setEditingContent] = useState(data.content);

    const [{ dragging }, drag] = useDrag({
        type: "list-item",
        item: {
            id: data.id,
        },
        collect(monitor) {
            return {
                dragging: monitor.isDragging(),
            };
        },
    });

    useEffect(() => {
        drag(ref);
    }, []);

    return (
        <div
            ref={ref}
            className={classNames(
                "h-100 border-2 border-black bg-blue-300 p-10",
                "flex justify-start items-center",
                "text-xl tracking-wide",
                dragging ? "bg-white border-dashed" : ""
            )}
            onDoubleClick={() => {
                setEditing(true);
            }}>
            <input
                type="checkbox"
                className="w-40 h-40 mr-10"
                checked={data.status === "done" ? true : false}
                onChange={(e) => {
                    updateItem({
                        ...data,
                        status: e.target.checked ? "done" : "todo",
                    });
                }}
            />
            <p>
                {editing ? (
                    <input
                        value={editingContent}
                        onChange={(e) => {
                            setEditingContent(e.target.value);
                        }}
                        onBlur={() => {
                            setEditing(false);
                            updateItem({
                                ...data,
                                content: editingContent,
                            });
                        }}
                    />
                ) : (
                    data.content
                )}
            </p>
        </div>
    );
}

还有,现在不管拖动到哪里都是在后面插入:

我们希望能根据 drop 的位置来插入:

所以给 Gap 传入 id 参数:

然后 Gap 组件 drop 的时候传入 addItem 方法:

addItem 方法里根据 id 插入:

没有传就插入在后面,否则 findIndex,然后在那个位置插入。

测试下:

没啥问题。

不过 gap 区域有点小,大家实现的时候可以改大一点。

还有,现在一刷新,数据就没了:

我们给 zustand 加上 persist 中间件:

注意,ts + middleware 的场景,zustand 要换这种写法。

文档的解释是为了更好的处理类型:

反正功能是一样的。

import { StateCreator, create } from "zustand";
import { persist } from "zustand/middleware";

export interface ListItem {
    id: string;
    status: "todo" | "done";
    content: string;
}

type State = {
    list: Array<ListItem>;
};

type Action = {
    addItem: (item: ListItem, id?: string) => void;
    deleteItem: (id: string) => void;
    updateItem: (item: ListItem) => void;
};

const stateCreator: StateCreator<State & Action> = (set) => ({
    list: [],
    addItem: (item: ListItem, id?: string) => {
        set((state) => {
            if (!id) {
                return {
                    list: [...state.list, item],
                };
            }

            const newList = [...state.list];

            const index = newList.findIndex((item) => item.id === id);

            newList.splice(index, 0, item);

            return {
                list: newList,
            };
        });
    },
    deleteItem: (id: string) => {
        set((state) => {
            return {
                list: state.list.filter((item) => {
                    return item.id !== id;
                }),
            };
        });
    },
    updateItem: (updateItem: ListItem) => {
        set((state) => {
            return {
                list: state.list.map((item) => {
                    if (item.id === updateItem.id) {
                        return updateItem;
                    }
                    return item;
                }),
            };
        });
    },
});

export const useTodoListStore = create<State & Action>()(
    persist(stateCreator, {
        name: "todolist",
    })
);

测试下:

现在,数据就被保存到了 localstorage 中,刷新数据也不会丢失。

这样,拖拽版 TodoList 就完成了。

大家还可以加个拖拽排序功能,和上节实现一样。

最后,我们加上过渡动画,用 react-spring:

npm install --save @react-spring/web

然后渲染 list 的时候用 react-spring 的 useTransition 的 hook 处理下:

useTransition 会根据传入的配置来生成 style,这些 style 要加在 animated.div 上。

并且,keys 也是在配置里传入的,animated.div 会自动添加。

案例代码上传了小册仓库

总结

我们用 react-dnd + zustand 实现了拖拽版 todolist。

用 tailwind 来写的样式。

用 @react-spring/web 加上了过渡动画。

这是个综合实战,对 react-dnd、tailwind、zustand、react-spring 都有较全面的应用。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
51.基于react-dnd实现拖拽排序
Next
53.ReactPlayground项目实战:需求分析、实现原理