本文为译文,原文地址为:
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-listcd todo-listmkdir client server
进入client
目录,并创立一个React我的项目。
cd clientnpx 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.jsconst 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 importsconst 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() blocksocketIO.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/srcmkdir componentscd componentstouch 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:[email protected];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/componentstouch Nav.js AddTask.js TasksContainer.js
在Task.js
援用下面的三个组件。
// Task.jsimport 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 stringconst fetchID = () => Math.random().toString(36).substring(2, 10);// Nested objectlet 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 routeapp.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 propconst 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插件版。