IdbI

第52章—react-dnd实战:拖拽版TodoList

学了很多技术之后,这节来综合练习下,做个 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 都有较全面的应用。

返回上级