从无到有做全栈,实现一个书签应用(一)

Posted on Fri 09 May 2025 in Computer Science

我过去的开发经验都聚焦于后端开发,也使用过很多后端框架,如Gin (Golang) 和 Django (Python)。但是由于工作需求限制,我几乎从来没有接触过javascript。但是从网上的经验,我也大概了解过整个基于javascript生态的全栈开发,非常适合独立开发者和小团队进行开发。现在有了更多的空闲时间去探索,我决定学习这个新的体系。但是我在网上搜索的时候,很难找到一个真正适合我这种既是专业人士,有同时是小白的人士。因此我觉得自己动手写一个。当然,说是自己动手写也不准确。我期望能够利用现在的LLM技术,更快的帮我掌握这个领域的基本知识。本文所涉及的代码约80%是由AI写出来的,并经由我进行调试和反馈。

我最初关于这个项目的想法是从非常简单的主意开始。这个系统不应该涉及任何支付,认证,安全和其他需要花费大量时间进行开发和调试的复杂问题。因此,我设定了以下学习目标:

  1. 学习javascript语言,使用一些常见框架开发一个前后端的完整项目。
  2. 部署到互联网上的任意平台,能够进行在线使用。
  3. 尽量运用最佳实践。

带着这些想法,我进行了一些头脑风暴。最终,我设定了这个想法:开发一个书签管理器

概念:一个可以保存,分类和分享网页链接的地方。 核心功能:书签的CRUD,列表和筛选。 学习要点:一些基础的Javascript前后端功能,连接数据库。

在开始这个项目之前,我假设你:

  • 熟悉 git 操作。
  • 熟悉 HTTP 及其最佳实践。
  • 能够 ( 非常概括地 ) 理解 javascript ( 或 typescript ) 。
  • 能够熟练使用 docker 命令。
  • 能够熟练使用终端。
  • 当出现意想不到的情况时,能够阅读并进行搜索来调查问题。

即使你不满足其中某些要求,也可以继续阅读。当遇到不熟悉的内容时,你可以进一步研究。我们将采用自下而上的方法来实现和运行。但你也可以选择自上而下的方法:只需完成数据库配置,然后跳到后面的章节,先体验一下这个项目,然后再回头阅读任何代码。无论哪种方式,都由你选择。话不多说,我们开始吧。

从无到有启动Bookmark项目

我一直想尝试这种 monorepo 的概念,即前后端都用类似的语言,并在同一个目录下进行开发。

mkdir bookmark-app
cd bookmark-app
# then 
mkdir server
mkdir client

接着进入 server 文件夹并设置项目结构。这个项目的选型主要会使用 express.js ,数据层使用 Mongoose ,然后使用 dotenv 来为这个项目设置不同的配置。

cd server

# 初始化Node项目
npm init -y

# 安装核心依赖
npm install express mongoose dotenv

# 安装开发依赖
npm install --save-dev nodemon

# 创建基本的服务器文件
touch server.js

# 创建一个简单的.gitignore文件
echo "node_modules/" > .gitignore
echo ".env" >> .gitignore

打开 package.json 并编辑 scripts 部分,加入以下内容。这部分内容的意思是是使用nodemon来启动服务。nodemon能帮助我们边开发边重启服务。

"scripts": {
   "test": "echo \"Error: no test specified\" && exit 1",
   "dev": "nodemon server.js"
},

接下来我们来创建一个React项目。这里我们使用了Vite提供的React模版,并安装安装了axios用于给后端发送API请求。

# 使用 Vite 在 client 文件夹内创建一个新的 React 项目
# 被提示安装 'create-vite',输入 'y'。
# 选择 'React' 和  'TypeScript' 。
npm create vite@latest client --template react

cd client

# 安装项目依赖
npm install

# 安装 axios
npm install axios

完成这些之后,项目结构应该像下面这样 1

bookmark-app
├── client
   ├── README.md
   ├── eslint.config.js
   ├── index.html
   ├── package-lock.json
   ├── package.json
   ├── public
      └── vite.svg
   ├── src
      ├── App.css
      ├── App.tsx
      ├── assets
         └── react.svg
      ├── index.css
      ├── main.tsx
      └── vite-env.d.ts
   ├── tsconfig.app.json
   ├── tsconfig.json
   ├── tsconfig.node.json
   └── vite.config.ts
└── server
    ├── package-lock.json
    ├── package.json
    └── server.js

简单启用一下服务端

如果我们将以下代码放入 server/server.js 文件中,我们就拥有了一个可运行的 API 端点。现在我们可以在 server 文件夹中运行 npm run dev 来启动后端服务器。因为我们是用了nodemon,当代码发生改变的时候,它还具备自动重新加载的功能。我们在浏览器中直接访问localhost:3000 应该能够访问该页面并看到 Bookmark API is running!

// Example server.js start
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('Bookmark API is running!');
});

app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

设置数据库

在这个项目中,我选择了 mongodb 来作为数据库,因为它文档模型提供了极佳的易用性。获取 mongodb 的方式有很多种。如果你已经安装了 mongodb,可以跳过这部分。我选择通过 docker 来安装,也同样是因为它简单易用。我同时安装了Docker Desktop来进行可视化操作。

# 拉取镜像
docker pull mongodb/mongodb-community-server:latest

# 启动mongodb
docker run --name mongodb -p 27017:27017 -d mongodb/mongodb-community-server:latest


# 安装 mongosh
brew install mongosh

# 通过 mongosh 连接到数据库
mongosh --port 27017

如果一切顺利,我们现在已经连接到 mongodb shell 。我们简单测试一下,应该会得到类似下面的输出。

test> db.runCommand(
...    {
...       hello: 1
...    }
... )
{
  isWritablePrimary: true,
  topologyVersion: {
    processId: ObjectId('6812ad7c13fca01873314556'),
    counter: Long('0')
  },
  maxBsonObjectSize: 16777216,
  maxMessageSizeBytes: 48000000,
  maxWriteBatchSize: 100000,
  localTime: ISODate('2025-04-30T23:09:40.733Z'),
  logicalSessionTimeoutMinutes: 30,
  connectionId: 4,
  minWireVersion: 0,
  maxWireVersion: 25,
  readOnly: false,
  ok: 1
}

实现服务端逻辑

回到 server 文件夹,创建一个 .env 文件并添加以下行:

DATABASE_URL = "mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.5.0"

在 server.js 文件中,添加以下代码:

// Connect to MongoDB
const mongoose = require('mongoose');
mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true, useUnifiedTopology: true })
    .then(() => console.log('MongoDB Connected'))
    .catch(err => console.error('MongoDB Connection Error:', err));

让我们创建一个代表书签的模型。 Mongoose 提供了一种与数据交互的抽象方式。在 models/bookmark.js 文件中,我们定义书签表的结构 ( schema ) 。

const mongoose = require('mongoose');

const isValidUrl = (url) => {
    try {
        new URL(url);
        return true;
    } catch {
        return false;
    }
};

const bookmarkSchema = new mongoose.Schema({
    url: {
        type: String,
        required: true,
        validate: {
            validator: isValidUrl,
            message: 'Invalid URL format'
        }
    },
    title: {
        type: String,
        required: true,
        maxlength: 200
    },
    description: {
        type: String,
        maxlength: 1000
    },
    tags: {
        type: [String],
        default: [],
        validate: {
            validator: (tags) => tags.length <= 10,
            message: 'Maximum 10 tags allowed'
        }
    },
    createdAt: { type: Date, default: Date.now }
});

module.exports = mongoose.model('Bookmark', bookmarkSchema);

该模型包含一个 URL 、一个标题、一些字符串描述以及一个字符串列表形式的标签。并且增加了一些校验和限制的逻辑。

在 routes/bookmark.js 中创建一个新文件,让我们添加第一个用于查询书签的 api 端点。

const express = require('express');
const router = express.Router(); 
const Bookmark = require('../models/Bookmark');

router.get('/', async (req, res) => {
    try {
        const bookmarks = await Bookmark.find().sort({ createdAt: -1 });
        res.json(bookmarks);
    } catch (err) {
        console.error(err.message);
        res.status(500).send('Server Error');
    }
});

这个方法使用了书签的 schema ,查询了所有的书签并按创建时间排序。然后,我们可以添加一个创建书签的方法。这里我们只添加了基本的验证,并暴露了所有的路由函数。

router.post('/', async (req, res) => {
    const { url, title, description, tags } = req.body;

    if (!url || !title) {
        return res.status(400).json({ msg: 'Please include a URL and Title' });
    }

    try {
        const newBookmark = new Bookmark({
            url,
            title,
            description,
            tags
        });

        const bookmark = await newBookmark.save();
        res.status(201).json(bookmark);
    } catch (err) {
        console.error(err.message);
        res.status(500).send('Server Error');
    }
});

我们还需要让服务器在特定的端点使用这些 api 定义。在 server.js 中,在 /api/v1/bookmarks 路径添加路由。

app.use('/api/v1/bookmarks', require('./routes/bookmark'));

这样,我们就可以使用 npm run dev 来启动服务器了。我使用了 Postman 来测试这些 api 端点。我附上了 cURL 请求以供参考,可以直接在终端中使用它们,或者作为请求导入到 Postman 中。

这个请求创建一个新的书签。

curl --location '127.0.0.1:3000/api/v1/bookmark' \
--header 'Content-Type: application/json' \
--data '{
    "url": "http://google.com",
    "title": "Google",
    "description": "Put google as a bookmark",
    "tags": [
        "abc",
        "def",
        "ghi"
    ]
}'

响应会是这样的:

{
    "url": "http://google.com",
    "title": "Google",
    "description": "Put google as a bookmark",
    "tags": [
        "abc",
        "def",
        "ghi"
    ],
    "_id": "68153dfd10539ed8509c8576",
    "createdAt": "2025-05-02T21:49:49.817Z",
    "__v": 0
}

以下请求应该会查询数据库中所有现存的书签,并按创建时间排序。

curl --location '127.0.0.1:3000/api/v1/bookmark'

响应会是这样的:

[
    {
        "_id": "68153dfd10539ed8509c8576",
        "url": "http://google.com",
        "title": "Google",
        "description": "Put google as a bookmark",
        "tags": [
            "abc",
            "def",
            "ghi"
        ],
        "createdAt": "2025-05-02T21:49:49.817Z",
        "__v": 0
    }
]

让我们在 mongodb shell 中检查一下。在命令行中使用 mongosh -port 27017 启动mongodb shell,并使用下面命令。

test> db.getCollectionNames()
[ 'bookmarks' ]
test> db.bookmarks.find({})
[
  {
    _id: ObjectId('68153dfd10539ed8509c8576'),
    url: 'http://google.com',
    title: 'Google',
    description: 'Put google as a bookmark',
    tags: [ 'abc', 'def', 'ghi' ],
    createdAt: ISODate('2025-05-02T21:49:49.817Z'),
    __v: 0
  }
]

到目前为止,我们已经实现了创建和查询书签列表的基本逻辑。我还是用同样的方式,创建了修改和删除的逻辑,但在这里我们就不一一赘述了。你可以进入本项目的代码目录进行更细致的研究。

Implement client logic

对于客户端逻辑,我们将主要使用基本的 React 来实现。由于客户端代码相当复杂,我不会从仓库中复制粘贴到本文的正文内容中。但我会给你一个高层次的概述。首先,客户端的文件树结构如下所示。

client
├── README.md
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
   └── vite.svg
├── src
   ├── App.css
   ├── App.tsx
   ├── assets
      └── react.svg
   ├── components
      ├── AddBookmarkModal.module.css
      ├── AddBookmarkModal.tsx
      ├── BookmarkItem.module.css
      ├── BookmarkItem.tsx
      ├── BookmarkList.module.css
      ├── BookmarkList.tsx
      ├── Toast.module.css
      ├── Toast.tsx
      └── ToastContainer.tsx
   ├── config
   ├── main.tsx
   ├── types
      └── bookmark.ts
   └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

每个文件都有其独特的功能,我会尽力解释它们的作用。我会跳过 vite 生成的样板代码,因为它们不是我们主要关注的内容。它们只是为了简化开发过程而存在的。

  • main.tsx 是应用程序的主要入口点。它很简单。目前,它只包含对 App.tsx 的引用。除此之外,通常它只用于导入一些通用的东西,例如导入和应用全局样式、设置全局上下文提供者,或者为整个应用程序初始化国际化。
  • App.tsx 是书签应用程序样式的主要入口点。它定义了应用程序的结构。 App.tsx 中的所有内容都应该引用 components 文件夹中的各个组件。
  • components 文件夹包含了为应用程序定义的所有基本模块。对于每个组件,我们定义了主要的模块文件 *.tsx和相关的样式表文件 *.module.css( 如果有的话 ) 。
  • AddBookmarkModal.tsx 是一个处理用户创建新书签输入的组件。它呈现一个表单,并调用相关的后端 api 来处理表单上传。此外,它的抽象程度较高,可以同时处理编辑和添加新书签的逻辑。
  • BookmarkItem.tsx 是用于显示每个单独书签卡片的主要组件。它还包括用于正确显示链接和图标的验证逻辑。
  • BookmarkList.tsx 是查询和显示所有书签项的核心逻辑,以及调用 AddBookmarkModal 的逻辑。
  • Toast.tsx 用于在右上角显示单个的提示信息。
  • ToastContainer.tsx 用于控制所有正在显示和已关闭的提示信息。

什么是 Modal ( 模态框 ) ?

可以把 Modal ( 模态框 ) 看作是显示在用户屏幕上的一个临时的、集中的覆盖层。它主要用于中断用户当前的工作流程,并引导用户进行特定操作。在我们的例子中,当用户点击添加或编辑按钮后,我们的 AddBookmarkModal 会在屏幕中央创建一个输入表单,并向相应的后端 api 发送请求。

什么是 Toast ( 吐司提示 ) ?

可以把 Toast 看作是一种简短的、非侵入式的弹出方式,用于向用户显示重要的通知。在我们的例子中,我们用它来显示错误信息。

Fire up the project

让我们把一切都启动起来。

首先,你需要克隆项目。在运行 git clone git@github.com:Mestrace/bookmark-app.git 之后。你需要将代码库切换到 192ef0c 这个分支,使用 git checkout 192ef0c 命令。

首先,使用我们之前展示的 mongosh 命令来验证你的数据库是否正在运行。

在一个新的命令行界面中,进入 server 文件夹,通过运行 npm install 安装所有依赖项。然后,你可以运行 npm run dev 来在端口 3000 启动服务器。目前这个端口号在前端项目中是硬编码的,所以你要么不做任何更改,要么必须同时更改 clientserver 文件夹中的设置。你应该等待并查看你的 server shell 中是否有任何错误信息。

在一个新的命令行界面中,进入 client 文件夹,通过运行 npm install 安装所有依赖项。然后,你可以运行 npm run dev 来在端口 5173 启动客户端。你可以点击终端中显示的链接,它会自动在浏览器中打开。然后你就可以体验所有的功能了。

Summary

这基本上就是整个项目了,完全从零开始。恭喜你,我和你都走到了这一步。从实践的角度来看,这个项目并不完美。我们没有忽略了生产环境中的很多事情,比如性能和安全性。我认为核心理念是,我们一开始就有一个可以动手实践的东西,所有的不完美都可以在后续的优化中处理。如果你和我一样是初学者,那么理解整个项目全貌至关重要,我不会让你我陷入各种元素的细枝末节中,因为在短时间理解项目的所有组成部分并不现实。

在接下来的文章中,我将继续解决前后端存在的一些问题。然后我们会把它部署到某个网络平台,让它正式上线。

  1. 你可以使用 tree bookmark-app --gitignore 来可视化这个结构。在 MacOS 上安装可以使用这个命令 brew install tree ↩︎