本文为译文,原文地址为:
Building a Kanban board with Node.js, React and Websockets
对于
在这篇文章中,你能够学习如何构建一个看板利用,相似在 JIRA, MonDay 或者 Trello 等利用中看到那样。这个利用,将蕴含一个丑陋的 drag-and-drop 性能,应用的技术是 React, Socket.io 和 DND(拖拽)技术。用户能够登录、创立并更新不同的工作,也能够增加评论。
Socket.io
Socket.io 是一个风行的 Javascript 库,能够在浏览器和 Node.js 服务端之间创立实时的、双向的通信。它有着很高的性能,哪怕是解决大量的数据,也能做到牢靠、低延时。它恪守 WebSocket 协定,但提供更好的性能,比方容错为 HTTP 长连贯以及主动重连,这样能构建更为无效的实时利用。
开始创立
创立我的项目根目录,蕴含两个子文件夹 client
和server
mkdir todo-list
cd todo-list
mkdir client server
进入 client
目录,并创立一个 React 我的项目。
cd client
npx create-react-app ./
装置 Socket.is Client API 和 React Router.React Router
帮咱们解决利用中的路由跳转问题。
npm install socket.io-client react-router-dom
删除无用的代码,比方 Logo 之类的,并批改 App.js
为以下代码。
function App() {
return (
<div>
<p>Hello World!</p>
</div>
);
}
export default App;
切换到 server 目录,并创立一个 package.json
文件。
cd server && npm init -y
装置 Express.js, CORS,Nodemon 和 Socket.io 服务端 API.
Express.js 是一个疾速、极简的 Node.js 框架。CORS 能够用来处于跨域问题。Nodemon 是一个 Node.js 开发者工具,当我的项目文件扭转时,它能主动重启 Node Sever。
npm install express cors nodemon socket.io
创立入口文件index.js
touch index.js
上面,用 Express.js 创立一个简略的 Node 服务。当你在浏览器中拜访 http://localhost:4000/api 时,上面的代码片断将返回一个 JSON 对象。
//👇🏻index.js
const express = require("express");
const app = express();
const PORT = 4000;
app.use(express.urlencoded({ extended: true}));
app.use(express.json());
app.get("/api", (req, res) => {
res.json({message: "Hello world",});
});
app.listen(PORT, () => {console.log(`Server listening on ${PORT}`);
});
启动以上服务
node index.js
批改一下 index.js,引入 http 和 cors 包,以容许数据在不同域名之间传输。
const express = require("express");
const app = express();
const PORT = 4000;
app.use(express.urlencoded({ extended: true}));
app.use(express.json());
//New imports
const http = require("http").Server(app);
const cors = require("cors");
app.use(cors());
app.get("/api", (req, res) => {
res.json({message: "Hello world",});
});
http.listen(PORT, () => {console.log(`Server listening on ${PORT}`);
});
接下来,咱们在 app.get()
代码块上方,增加以下代码,用 socket.io 创立实时连贯。
// New imports
// .....
const socketIO = require('socket.io')(http, {
cors: {origin: "http://localhost:3000"}
});
//Add this before the app.get() block
socketIO.on('connection', (socket) => {console.log(`⚡: ${socket.id} user just connected!`);
socket.on('disconnect', () => {socket.disconnect()
console.log('🔥: A user disconnected');
});
});
以下代码中,当有用户拜访页面时,socket.io("connection")
办法创立了一个与客户端(client React 我的项目)的连贯,生成一个惟一 ID,并通过 console 输入到命令行窗口。
当你刷新或者敞开页面时,会触发 disconnect
事件。
以上代码,每次编辑后,都须要手动重启 node index.js
,很不不便。咱们配置一下 Nodemon,以实现自动更新。在package.json
文件中增加以下代码。
//In server/package.json
"scripts": {
"test": "echo \"Error: no test specified\"&& exit 1",
"start": "nodemon index.js"
},
这样,咱们就能够用以下命令来启动服务。
npm start
创立用户界面
客户端用户界面,蕴含 Login Page/Task Page 和 Comment Page 三个页面。
cd client/src
mkdir components
cd components
touch Login.js Task.js Comments.js
更新 App.js
为以下代码。
import {BrowserRouter, Route, Routes} from "react-router-dom";
import Comments from "./components/Comments";
import Task from "./components/Task";
import Login from "./components/Login";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path='/' element={<Login />} />
<Route path='/task' element={<Task />} />
<Route path='/comments/:category/:id' element={<Comments />} />
</Routes>
</BrowserRouter>
);
}
export default App;
批改 src/index.css
为以下款式.
* {
font-family: "Space Grotesk", sans-serif;
box-sizing: border-box;
}
a {text-decoration: none;}
body {
margin: 0;
padding: 0;
}
.navbar {
width: 100%;
background-color: #f1f7ee;
height: 10vh;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
}
.form__input {
min-height: 20vh;
display: flex;
align-items: center;
justify-content: center;
}
.input {
margin: 0 5px;
width: 50%;
padding: 10px 15px;
}
.addTodoBtn {
width: 150px;
padding: 10px;
cursor: pointer;
background-color: #367e18;
color: #fff;
border: none;
outline: none;
height: 43px;
}
.container {
width: 100%;
min-height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
}
.completed__wrapper,
.ongoing__wrapper,
.pending__wrapper {
width: 32%;
min-height: 60vh;
display: flex;
flex-direction: column;
padding: 5px;
}
.ongoing__wrapper > h3,
.pending__wrapper > h3,
.completed__wrapper > h3 {
text-align: center;
text-transform: capitalize;
}
.pending__items {background-color: #eee3cb;}
.ongoing__items {background-color: #d2daff;}
.completed__items {background-color: #7fb77e;}
.pending__container,
.ongoing__container,
.completed__container {
width: 100%;
min-height: 55vh;
display: flex;
flex-direction: column;
padding: 5px;
border: 1px solid #ddd;
border-radius: 5px;
}
.pending__items,
.ongoing__items,
.completed__items {
width: 100%;
border-radius: 5px;
margin-bottom: 10px;
padding: 15px;
}
.comment {
text-align: right;
font-size: 14px;
cursor: pointer;
color: rgb(85, 85, 199);
}
.comment:hover {text-decoration: underline;}
.comments__container {padding: 20px;}
.comment__form {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-bottom: 30px;
}
.comment__form > label {margin-bottom: 15px;}
.comment__form textarea {
width: 80%;
padding: 15px;
margin-bottom: 15px;
}
.commentBtn {
padding: 10px;
width: 200px;
background-color: #367e18;
outline: none;
border: none;
color: #fff;
height: 45px;
cursor: pointer;
}
.comments__section {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.login__form {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.login__form > label {margin-bottom: 15px;}
.login__form > input {
width: 70%;
padding: 10px 15px;
margin-bottom: 15px;
}
.login__form > button {
background-color: #367e18;
color: #fff;
padding: 15px;
cursor: pointer;
border: none;
font-size: 16px;
outline: none;
width: 200px;
}
Login Page
登录页接管 username
参数,将其存在 local storage 中用于用户认证。
更新 Login.js
如下:
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
const Login = () => {const [username, setUsername] = useState("");
const navigate = useNavigate();
const handleLogin = (e) => {e.preventDefault();
//👇🏻 saves the username to localstorage
localStorage.setItem("userId", username);
setUsername("");
//👇🏻 redirects to the Tasks page.
navigate("/tasks");
};
return (
<div className='login__container'>
<form className='login__form' onSubmit={handleLogin}>
<label htmlFor='username'>Provide a username</label>
<input
type='text'
name='username'
id='username'
required
onChange={(e) => setUsername(e.target.value)}
value={username}
/>
<button>SIGN IN</button>
</form>
</div>
);
};
export default Login;
Task Page
工作页是该利用的主体页面,最终成果如下图。其分为三个局部:Nav.js
,AddTask.js
- 解决用户输出,和TaskContainer.js
- 工作列表。
cd src/components
touch Nav.js AddTask.js TasksContainer.js
在 Task.js
援用下面的三个组件。
// Task.js
import React from "react";
import AddTask from "./AddTask";
import TasksContainer from "./TasksContainer";
import Nav from "./Nav";
import socketIO from "socket.io-client";
const socket = socketIO.connect("http://localhost:4000");
const Task = () => {
return (
<div>
<Nav />
<AddTask socket={socket} />
<TasksContainer socket={socket} />
</div>
);
};
export default Task;
上面是Nav.js
import React from "react";
const Nav = () => {
return (
<nav className='navbar'>
<h3>Team's todo list</h3>
</nav>
);
};
export default Nav;
AddTask.js
如下:
import React, {useState} from "react";
const AddTask = ({socket}) => {const [task, setTask] = useState("");
const handleAddTodo = (e) => {e.preventDefault();
//👇🏻 Logs the task to the console
console.log({task});
setTask("");
};
return (<form className='form__input' onSubmit={handleAddTodo}>
<label htmlFor='task'>Add Todo</label>
<input
type='text'
name='task'
id='task'
value={task}
className='input'
required
onChange={(e) => setTask(e.target.value)}
/>
<button className='addTodoBtn'>ADD TODO</button>
</form>
);
};
export default AddTask;
TaskContainer.js
如下:
import React from "react";
import {Link} from "react-router-dom";
const TasksContainer = ({socket}) => {
return (
<div className='container'>
<div className='pending__wrapper'>
<h3>Pending Tasks</h3>
<div className='pending__container'>
<div className='pending__items'>
<p>Debug the Notification center</p>
<p className='comment'>
<Link to='/comments'>2 Comments</Link>
</p>
</div>
</div>
</div>
<div className='ongoing__wrapper'>
<h3>Ongoing Tasks</h3>
<div className='ongoing__container'>
<div className='ongoing__items'>
<p>Create designs for Novu</p>
<p className='comment'>
<Link to='/comments'>Add Comment</Link>
</p>
</div>
</div>
</div>
<div className='completed__wrapper'>
<h3>Completed Tasks</h3>
<div className='completed__container'>
<div className='completed__items'>
<p>Debug the Notification center</p>
<p className='comment'>
<Link to='/comments'>2 Comments</Link>
</p>
</div>
</div>
</div>
</div>
);
};
export default TasksContainer;
祝贺你!页面布局已实现。上面,咱们为评论页面创立一个简略的模板。
Comments Page(评论页)
Comments.js
代码如下:
import React, {useEffect, useState} from "react";
import socketIO from "socket.io-client";
import {useParams} from "react-router-dom";
const socket = socketIO.connect("http://localhost:4000");
const Comments = () => {const [comment, setComment] = useState("");
const addComment = (e) => {e.preventDefault();
console.log({
comment,
userId: localStorage.getItem("userId"),
});
setComment("");
};
return (
<div className='comments__container'>
<form className='comment__form' onSubmit={addComment}>
<label htmlFor='comment'>Add a comment</label>
<textarea
placeholder='Type your comment...'
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={5}
id='comment'
name='comment'
required
></textarea>
<button className='commentBtn'>ADD COMMENT</button>
</form>
<div className='comments__section'>
<h2>Existing Comments</h2>
<div></div>
</div>
</div>
);
};
export default Comments;
这样,所有页面的基本功能就实现了,运行以下命令看看成果。
cd client/
npm start
如用应用 react-beautiful-dnd 增加拖拽成果
这一大节,你将学会在 React 利用中增加 react-beautiful-dnd
组件,使得工作能够从不同分类(pending, ongoing, completed)中挪动。
关上server/index.js
, 创立一个变量来存储模仿的数据,如下:
//👇🏻 server/index.js
//👇🏻 Generates a random string
const fetchID = () => Math.random().toString(36).substring(2, 10);
//👇🏻 Nested object
let tasks = {
pending: {
title: "pending",
items: [
{id: fetchID(),
title: "Send the Figma file to Dima",
comments: [],},
],
},
ongoing: {
title: "ongoing",
items: [
{id: fetchID(),
title: "Review GitHub issues",
comments: [
{
name: "David",
text: "Ensure you review before merging",
id: fetchID(),},
],
},
],
},
completed: {
title: "completed",
items: [
{id: fetchID(),
title: "Create technical contents",
comments: [
{
name: "Dima",
text: "Make sure you check the requirements",
id: fetchID(),},
],
},
],
},
};
//👇🏻 host the tasks object via the /api route
app.get("/api", (req, res) => {res.json(tasks);
});
在 TasksContainer.js
文件中,获取 tasks 数据,并转成数组渲染进去。如下:
import React, {useState, useEffect} from "react";
import {Link} from "react-router-dom";
const TasksContainer = () => {const [tasks, setTasks] = useState({});
useEffect(() => {function fetchTasks() {fetch("http://localhost:4000/api")
.then((res) => res.json())
.then((data) => {console.log(data);
setTasks(data);
});
}
fetchTasks();}, []);
return (
<div className='container'>
{{Object.entries(tasks).map((task) => (
<div
className={`${task[1].title.toLowerCase()}__wrapper`}
key={task[1].title}
>
<h3>{task[1].title} Tasks</h3>
<div className={`${task[1].title.toLowerCase()}__container`}>
{task[1].items.map((item, index) => (
<div
className={`${task[1].title.toLowerCase()}__items`}
key={item.id}
>
<p>{item.title}</p>
<p className='comment'>
<Link to='/comments'>
{item.comments.length > 0 ? `View Comments` : "Add Comment"}
</Link>
</p>
</div>
))}
</div>
</div>
))}
</div>
);
};
export default TasksContainer;
装置 react-beautiful-dnd
,并在在TasksContainer.js
中援用依赖。
npm install react-beautiful-dnd
更新 TasksContainer.js
的 import 局部:
import {DragDropContext, Droppable, Draggable} from "react-beautiful-dnd";
更新 TasksContainer.js
的render
局部:
return (
<div className='container'>
{/** --- 👇🏻 DragDropContext ---- */}
<DragDropContext onDragEnd={handleDragEnd}>
{Object.entries(tasks).map((task) => (
<div
className={`${task[1].title.toLowerCase()}__wrapper`}
key={task[1].title}
>
<h3>{task[1].title} Tasks</h3>
<div className={`${task[1].title.toLowerCase()}__container`}>
{/** --- 👇🏻 Droppable --- */}
<Droppable droppableId={task[1].title}>
{(provided) => (<div ref={provided.innerRef} {...provided.droppableProps}>
{task[1].items.map((item, index) => ({/** --- 👇🏻 Draggable --- */}
<Draggable
key={item.id}
draggableId={item.id}
index={index}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={`${task[1].title.toLowerCase()}__items`}
>
<p>{item.title}</p>
<p className='comment'>
<Link to={`/comments/${task[1].title}/${item.id}`}>
{item.comments.length > 0
? `View Comments`
: "Add Comment"}
</Link>
</p>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
</div>
))}
</DragDropContext>
</div>
);
DragDropContext
包裹整个拖放 (drag-and-drop) 容器,Droppable
是draggable elements
的父元素。Droppable
组件须要传入draggableId
,Draggable
组件须要传入draggableId
。它们蕴含的子组件,能够通过provided
获取拖拽过程中的数据,如provided.draggableProps
、provided.drageHandleProp
等。
DragDropContext
还接管 onDragEnd
参数,用于拖动实现时的事件触发。
//👇🏻 This function is the value of the onDragEnd prop
const handleDragEnd = ({destination, source}) => {if (!destination) return;
if (
destination.index === source.index &&
destination.droppableId === source.droppableId
)
return;
socket.emit("taskDragged", {
source,
destination,
});
};
以上 handleDragEnd
函数,接管 destination
和source
这两个参 ovtt,并查看正在拖动的元素(source)是不是被拖动到一个能够 droppable
的指标(destination)元素上。如果 source
和destination
不一样,就通过 socket.io 给 Node.js server 发个音讯,示意工作被挪动了。
handleDragEnd
收到的参数,格局如下。
{source: { index: 0, droppableId: 'pending'},
destination: {droppableId: 'ongoing', index: 1}
}
在后端 server/index.js
中创立 taskDragged
事件,来解决下面发送过去的音讯。解决完后往客户端回复一个 tasks
事件。放在与 connection
事件处理函数的外部(与 disconnect
事件函数的地位同级),以确保 socket
是可用的。
socket.on("taskDragged", (data) => {const { source, destination} = data;
//👇🏻 Gets the item that was dragged
const itemMoved = {...tasks[source.droppableId].items[source.index],
};
console.log("DraggedItem>>>", itemMoved);
//👇🏻 Removes the item from the its source
tasks[source.droppableId].items.splice(source.index, 1);
//👇🏻 Add the item to its destination using its destination index
tasks[destination.droppableId].items.splice(destination.index, 0, itemMoved);
//👇🏻 Sends the updated tasks object to the React app
socket.emit("tasks", tasks);
/* 👇🏻 Print the items at the Source and Destination
console.log("Source >>>", tasks[source.droppableId].items);
console.log("Destination >>>", tasks[destination.droppableId].items);
*/
});
而后再在 TasksContainer
创立一个接管服务端 tasks
事件以监听获取最新的通过服务端解决(比方长久化到数据库)的 tasks 数据.
useEffect(() => {socket.on("tasks", (data) => setTasks(data));
}, [socket]);
这样,拖放的成果,就失效了。如下图:
小结一下
- client 端:TasksContainer,用户拖放操作,将数据以
taskDragged
事件的形式通过 socket 传给服务端 - server 端:接管
taskDragged
事件,将 tasks 数据处理后,以tasks
事件的形式推送到客户端 - client 端:客户端接管到
tasks
事件后,将本地 tasks 数据替换为最新的局部,页面就显示拖放后的成果了
如何创立新工作
这一大节,将疏导你如何在 React 利用中创立新的工作。
更新 AddTask.js
为以下代码,通过 createTask
事件向 server 端发送新工作的数据。
import React, {useState} from "react";
const AddTask = ({socket}) => {const [task, setTask] = useState("");
const handleAddTodo = (e) => {e.preventDefault();
//👇🏻 sends the task to the Socket.io server
socket.emit("createTask", { task});
setTask("");
};
return (<form className='form__input' onSubmit={handleAddTodo}>
<label htmlFor='task'>Add Todo</label>
<input
type='text'
name='task'
id='task'
value={task}
className='input'
required
onChange={(e) => setTask(e.target.value)}
/>
<button className='addTodoBtn'>ADD TODO</button>
</form>
);
};
export default AddTask;
在 server 端监听 createTask
事件,并在 tasks
数据中新增一条。
socketIO.on("connection", (socket) => {console.log(`⚡: ${socket.id} user just connected!`);
socket.on("createTask", (data) => {
// 👇🏻 Constructs an object according to the data structure
const newTask = {id: fetchID(), title: data.task, comments: []};
// 👇🏻 Adds the task to the pending category
tasks["pending"].items.push(newTask);
/*
👇🏻 Fires the tasks event for update
*/
socket.emit("tasks", tasks);
});
//...other listeners
});
实现评论性能
这个大节,你将学到如何在每个工作下评论,并获取所有评论的列表。
更新 Comments.js
,通过addComment
事件将评论数据传给服务端。如下:
import React, {useEffect, useState} from "react";
import socketIO from "socket.io-client";
import {useParams} from "react-router-dom";
const socket = socketIO.connect("http://localhost:4000");
const Comments = () => {const { category, id} = useParams();
const [comment, setComment] = useState("");
const addComment = (e) => {e.preventDefault();
/*
👇🏻 sends the comment, the task category, item's id and the userID.
*/
socket.emit("addComment", {
comment,
category,
id,
userId: localStorage.getItem("userId"),
});
setComment("");
};
return (
<div className='comments__container'>
<form className='comment__form' onSubmit={addComment}>
<label htmlFor='comment'>Add a comment</label>
<textarea
placeholder='Type your comment...'
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={5}
id='comment'
name='comment'
required
></textarea>
<button className='commentBtn'>ADD COMMENT</button>
</form>
<div className='comments__section'>
<h2>Existing Comments</h2>
<div></div>
</div>
</div>
);
};
export default Comments;
点击工作卡片中的 View Comments
进入 Comment 页面。填写评论的内容后点击 Add Comment
按钮,就能够将用户 ID、工作分类、评分内容发送到服务端。
接下来,在服务端监听 addComment
事件,将评论存入对应工作的 comments 列表中。解决实现后,再通过 comments
事件,将最新评论推送到客户端。
socket.on("addComment", (data) => {const { category, userId, comment, id} = data;
//👇🏻 Gets the items in the task's category
const taskItems = tasks[category].items;
//👇🏻 Loops through the list of items to find a matching ID
for (let i = 0; i < taskItems.length; i++) {if (taskItems[i].id === id) {//👇🏻 Then adds the comment to the list of comments under the item (task)
taskItems[i].comments.push({
name: userId,
text: comment,
id: fetchID(),});
//👇🏻 sends a new event to the React app
socket.emit("comments", taskItems[i].comments);
}
}
});
更新Comments.js
,从服务端获取评论列表。如下(留神不是残缺替换,只是新增了一些代码):
const Comments = () => {const { category, id} = useParams();
const [comment, setComment] = useState("");
const [commentList, setCommentList] = useState([]);
//👇🏻 Listens to the comments event
useEffect(() => {socket.on("comments", (data) => setCommentList(data));
}, []);
//...other listeners
return (
<div className='comments__container'>
<form className='comment__form' onSubmit={addComment}>
...
</form>
{/** 👇🏻 Displays all the available comments*/}
<div className='comments__section'>
<h2>Existing Comments</h2>
{commentList.map((comment) => (<div key={comment.id}>
<p>
<span style={{fontWeight: "bold"}}>{comment.text} </span>by{" "}
{comment.name}
</p>
</div>
))}
</div>
</div>
);
};
export default Comments;
添 useEffect
以解决初始页面加载评论的问题。
useEffect(() => {socket.emit("fetchComments", { category, id});
}, [category, id]);
相应地,服务端也要提供 fetchComments
接口。如下:
socket.on("fetchComments", (data) => {const { category, id} = data;
const taskItems = tasks[category].items;
for (let i = 0; i < taskItems.length; i++) {if (taskItems[i].id === id) {socket.emit("comments", taskItems[i].comments);
}
}
});
评论性能,成果如下图:
祝贺你,这么长的一篇文章居然看完了!
如果你不想一点点地复制,能够在这里获取残缺的代码。
最初
译文作者:liushuigs
创立于 RunJS Chrome 插件版。