本文为译文,原文地址为:

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长连贯以及主动重连,这样能构建更为无效的实时利用。

开始创立

创立我的项目根目录,蕴含两个子文件夹clientserver

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.jsAddTask.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.jsrender局部:

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)容器,Droppabledraggable elements的父元素。
  • Droppable组件须要传入draggableIdDraggable组件须要传入draggableId。它们蕴含的子组件,能够通过provided获取拖拽过程中的数据,如provided.draggablePropsprovided.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函数,接管destinationsource这两个参ovtt,并查看正在拖动的元素(source)是不是被拖动到一个能够droppable的指标(destination)元素上。如果sourcedestination不一样,就通过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插件版。