本文為譯文,原文地址為:
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為以下樣式.
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap");
* {
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插件版。